openspec-sdd-e2e-kit 0.1.0

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 (38) hide show
  1. package/README.md +63 -0
  2. package/bin/sdd-e2e-kit.mjs +53 -0
  3. package/kit/.codex/skills/feature-to-e2e/SKILL.md +188 -0
  4. package/kit/.codex/skills/feature-to-e2e/agents/openai.yaml +4 -0
  5. package/kit/.codex/skills/openspec-apply-change/SKILL.md +180 -0
  6. package/kit/.codex/skills/openspec-archive-change/SKILL.md +157 -0
  7. package/kit/.codex/skills/openspec-continue-change/SKILL.md +136 -0
  8. package/kit/.codex/skills/openspec-explore/SKILL.md +292 -0
  9. package/kit/.codex/skills/openspec-full-spec-discovery/SKILL.md +356 -0
  10. package/kit/.codex/skills/openspec-full-spec-discovery/references/backlog-row-to-main-spec.md +447 -0
  11. package/kit/.codex/skills/openspec-new-change/SKILL.md +92 -0
  12. package/kit/.codex/skills/openspec-propose/SKILL.md +132 -0
  13. package/kit/.codex/skills/spec-to-gherkin/SKILL.md +686 -0
  14. package/kit/SDD_E2E_FLOW.md +268 -0
  15. package/kit/manifest.json +78 -0
  16. package/kit/openspec/config.yaml +18 -0
  17. package/kit/openspec/schemas/sdd-e2e/schema.yaml +128 -0
  18. package/kit/openspec/schemas/sdd-e2e/templates/acceptance-coverage.md +9 -0
  19. package/kit/openspec/schemas/sdd-e2e/templates/design.md +29 -0
  20. package/kit/openspec/schemas/sdd-e2e/templates/feature.feature +13 -0
  21. package/kit/openspec/schemas/sdd-e2e/templates/proposal.md +23 -0
  22. package/kit/openspec/schemas/sdd-e2e/templates/spec.md +21 -0
  23. package/kit/openspec/schemas/sdd-e2e/templates/tasks.md +16 -0
  24. package/kit/openspec/schemas/sdd-e2e/templates/test-cases.md +35 -0
  25. package/kit/openspec/schemas/sdd-e2e.yaml +160 -0
  26. package/kit/openspec/sdd-e2e-flow.md +290 -0
  27. package/kit/openspec/sdd-e2e-maintenance.md +98 -0
  28. package/kit/scripts/sdd/check-report.mjs +34 -0
  29. package/kit/scripts/sdd/lib.mjs +290 -0
  30. package/kit/scripts/sdd/lint-features.mjs +60 -0
  31. package/kit/scripts/sdd/lint-tasks.mjs +41 -0
  32. package/kit/scripts/sdd/self-test.mjs +185 -0
  33. package/kit/scripts/sdd/summarize-acceptance.mjs +41 -0
  34. package/package.json +19 -0
  35. package/src/check.mjs +86 -0
  36. package/src/diff.mjs +101 -0
  37. package/src/install.mjs +159 -0
  38. package/src/lib.mjs +221 -0
@@ -0,0 +1,290 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const allowedValidationTypes = new Set([
5
+ 'mock-e2e',
6
+ 'real-smoke',
7
+ 'component',
8
+ 'unit',
9
+ 'api',
10
+ 'manual',
11
+ ]);
12
+
13
+ export const riskTags = new Set([
14
+ 'race-condition',
15
+ 'degradation',
16
+ 'anti-duplicate',
17
+ 'form-validation',
18
+ 'conditional-render',
19
+ 'cross-view-sync',
20
+ 'state-machine',
21
+ 'feedback',
22
+ 'negative',
23
+ 'data-echo',
24
+ ]);
25
+
26
+ export const automatedValidationTypes = new Set(
27
+ [...allowedValidationTypes].filter((type) => type !== 'manual'),
28
+ );
29
+
30
+ export function getChangeName() {
31
+ const change = process.argv[2];
32
+ if (!change) {
33
+ throw new Error('Usage: pnpm <sdd-command> <change> [phase]');
34
+ }
35
+ return change;
36
+ }
37
+
38
+ export function getChangeDir(change) {
39
+ return path.join(process.cwd(), 'openspec', 'changes', change);
40
+ }
41
+
42
+ export function assertExists(targetPath, label = targetPath) {
43
+ if (!fs.existsSync(targetPath)) {
44
+ throw new Error(`${label} not found: ${targetPath}`);
45
+ }
46
+ }
47
+
48
+ export function walkFiles(root, predicate = () => true) {
49
+ if (!fs.existsSync(root)) {
50
+ return [];
51
+ }
52
+
53
+ const results = [];
54
+ const entries = fs.readdirSync(root, { withFileTypes: true });
55
+
56
+ for (const entry of entries) {
57
+ const fullPath = path.join(root, entry.name);
58
+ if (entry.isDirectory()) {
59
+ results.push(...walkFiles(fullPath, predicate));
60
+ } else if (predicate(fullPath)) {
61
+ results.push(fullPath);
62
+ }
63
+ }
64
+
65
+ return results.sort();
66
+ }
67
+
68
+ export function findFeatureFiles(change) {
69
+ const specsDir = path.join(getChangeDir(change), 'specs');
70
+ return walkFiles(specsDir, (file) => file.endsWith('.feature'));
71
+ }
72
+
73
+ export function parseFeatureFile(file) {
74
+ const text = fs.readFileSync(file, 'utf8');
75
+ const lines = text.split(/\r?\n/);
76
+ const scenarios = [];
77
+ let pendingTags = [];
78
+ let featureTags = [];
79
+
80
+ lines.forEach((line, index) => {
81
+ const trimmed = line.trim();
82
+
83
+ if (!trimmed) {
84
+ return;
85
+ }
86
+
87
+ if (trimmed.startsWith('@')) {
88
+ pendingTags = trimmed.split(/\s+/).filter(Boolean);
89
+ return;
90
+ }
91
+
92
+ if (trimmed.startsWith('Feature:')) {
93
+ featureTags = pendingTags;
94
+ pendingTags = [];
95
+ return;
96
+ }
97
+
98
+ const scenarioMatch = trimmed.match(/^Scenario(?: Outline)?:\s*(.+)$/);
99
+ if (scenarioMatch) {
100
+ scenarios.push({
101
+ file,
102
+ line: index + 1,
103
+ name: scenarioMatch[1].trim(),
104
+ tags: pendingTags,
105
+ featureTags,
106
+ });
107
+ pendingTags = [];
108
+ return;
109
+ }
110
+
111
+ if (!trimmed.startsWith('#')) {
112
+ pendingTags = [];
113
+ }
114
+ });
115
+
116
+ return scenarios;
117
+ }
118
+
119
+ export function getFeatureScenarios(change) {
120
+ return findFeatureFiles(change).flatMap(parseFeatureFile);
121
+ }
122
+
123
+ export function getTagValue(tags, prefix) {
124
+ const tag = tags.find((item) => item.startsWith(prefix));
125
+ return tag ? tag.slice(prefix.length) : null;
126
+ }
127
+
128
+ export function hasPriority(tags) {
129
+ return tags.some((tag) => /^@p[0-2]$/.test(tag));
130
+ }
131
+
132
+ export function getScenarioPhases(change) {
133
+ return new Set(
134
+ getFeatureScenarios(change)
135
+ .map((scenario) => getTagValue(scenario.tags, '@phase:'))
136
+ .filter(Boolean),
137
+ );
138
+ }
139
+
140
+ export function getRequiredPhaseCoverage(change, phase) {
141
+ const required = getFeatureScenarios(change).filter((scenario) => {
142
+ const scenarioPhase = getTagValue(scenario.tags, '@phase:');
143
+ const validation = getTagValue(scenario.tags, '@validation:');
144
+ const isRequiredPriority = scenario.tags.includes('@p0') || scenario.tags.includes('@p1');
145
+ return (
146
+ scenarioPhase === phase &&
147
+ isRequiredPriority &&
148
+ !scenario.tags.includes('@gap-pending') &&
149
+ validation
150
+ );
151
+ });
152
+
153
+ const automated = required.filter((scenario) =>
154
+ automatedValidationTypes.has(getTagValue(scenario.tags, '@validation:')),
155
+ );
156
+
157
+ const manual = required.filter((scenario) => getTagValue(scenario.tags, '@validation:') === 'manual');
158
+
159
+ return {
160
+ required,
161
+ automated,
162
+ manual,
163
+ };
164
+ }
165
+
166
+ export function parseCoverageReport(report) {
167
+ const values = {};
168
+ const fields = ['selected', 'automated', 'passed', 'mismatch', 'blocked', 'manual', 'gap-pending'];
169
+
170
+ for (const field of fields) {
171
+ const match = report.match(new RegExp(`\\b${field}\\b\\s*[:|]\\s*(\\d+)`, 'i'));
172
+ if (match) {
173
+ values[field] = Number(match[1]);
174
+ }
175
+ }
176
+
177
+ const gateMatch = report.match(/\bgate\b\s*[:|]\s*(passed|failed)\b/i);
178
+ if (gateMatch) {
179
+ values.gate = gateMatch[1].toLowerCase();
180
+ }
181
+
182
+ return values;
183
+ }
184
+
185
+ export function validatePhaseReport(change, phase, reportPath) {
186
+ const errors = [];
187
+
188
+ if (!fs.existsSync(reportPath)) {
189
+ return {
190
+ errors: [`phase report not found: ${reportPath}`],
191
+ coverage: null,
192
+ expected: getRequiredPhaseCoverage(change, phase),
193
+ };
194
+ }
195
+
196
+ const report = fs.readFileSync(reportPath, 'utf8');
197
+ const coverage = parseCoverageReport(report);
198
+ const expected = getRequiredPhaseCoverage(change, phase);
199
+ const requiredFields = ['selected', 'automated', 'passed', 'mismatch', 'blocked', 'manual', 'gap-pending', 'gate'];
200
+
201
+ for (const field of requiredFields) {
202
+ if (coverage[field] === undefined) {
203
+ errors.push(`phase report missing coverage field: ${field}`);
204
+ }
205
+ }
206
+
207
+ if (coverage.selected !== undefined && coverage.selected < expected.required.length) {
208
+ errors.push(
209
+ `phase report selected=${coverage.selected} but expected at least ${expected.required.length} P0/P1 Scenario results`,
210
+ );
211
+ }
212
+
213
+ if (coverage.automated !== undefined && coverage.automated < expected.automated.length) {
214
+ errors.push(
215
+ `phase report automated=${coverage.automated} but expected ${expected.automated.length} automated P0/P1 Scenario results`,
216
+ );
217
+ }
218
+
219
+ if (coverage.passed !== undefined && coverage.passed < expected.automated.length) {
220
+ errors.push(
221
+ `phase report passed=${coverage.passed} but expected ${expected.automated.length} automated Scenario passed`,
222
+ );
223
+ }
224
+
225
+ if (coverage.manual !== undefined && coverage.manual < expected.manual.length) {
226
+ errors.push(
227
+ `phase report manual=${coverage.manual} but expected ${expected.manual.length} manual Scenario results`,
228
+ );
229
+ }
230
+
231
+ if (coverage.gate !== 'passed') {
232
+ errors.push('phase report gate is not passed');
233
+ }
234
+
235
+ for (const field of ['mismatch', 'blocked', 'manual']) {
236
+ if (
237
+ coverage[field] > 0 &&
238
+ !new RegExp(`${field}\\s+accepted exception|${field}.*已接受例外`, 'i').test(report)
239
+ ) {
240
+ errors.push(`phase report has ${field}=${coverage[field]} without accepted exception`);
241
+ }
242
+ }
243
+
244
+ return { errors, coverage, expected };
245
+ }
246
+
247
+ export function parseTaskPhases(change) {
248
+ const tasksPath = path.join(getChangeDir(change), 'tasks.md');
249
+ assertExists(tasksPath, 'tasks.md');
250
+
251
+ const text = fs.readFileSync(tasksPath, 'utf8');
252
+ const lines = text.split(/\r?\n/);
253
+ const phases = new Map();
254
+ let current = null;
255
+
256
+ lines.forEach((line, index) => {
257
+ const phaseMatch = line.match(/^## Phase:\s*([a-z0-9-]+)\s*$/);
258
+ if (phaseMatch) {
259
+ current = {
260
+ id: phaseMatch[1],
261
+ line: index + 1,
262
+ lines: [],
263
+ };
264
+ phases.set(current.id, current);
265
+ return;
266
+ }
267
+
268
+ if (current) {
269
+ current.lines.push(line);
270
+ }
271
+ });
272
+
273
+ return phases;
274
+ }
275
+
276
+ export function reportResult(title, errors, warnings = []) {
277
+ if (warnings.length) {
278
+ console.warn(`\n${title} warnings:`);
279
+ warnings.forEach((warning) => console.warn(`- ${warning}`));
280
+ }
281
+
282
+ if (errors.length) {
283
+ console.error(`\n${title} failed:`);
284
+ errors.forEach((error) => console.error(`- ${error}`));
285
+ process.exitCode = 1;
286
+ return;
287
+ }
288
+
289
+ console.log(`${title} passed`);
290
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ allowedValidationTypes,
3
+ findFeatureFiles,
4
+ getChangeName,
5
+ getFeatureScenarios,
6
+ getTagValue,
7
+ hasPriority,
8
+ reportResult,
9
+ riskTags,
10
+ } from './lib.mjs';
11
+
12
+ const change = getChangeName();
13
+ const featureFiles = findFeatureFiles(change);
14
+ const scenarios = getFeatureScenarios(change);
15
+ const errors = [];
16
+ const warnings = [];
17
+
18
+ if (!featureFiles.length) {
19
+ errors.push(`no feature files found for change "${change}"`);
20
+ }
21
+
22
+ if (!scenarios.length) {
23
+ errors.push(`no Scenario found for change "${change}"`);
24
+ }
25
+
26
+ for (const scenario of scenarios) {
27
+ const label = `${scenario.file}:${scenario.line} ${scenario.name}`;
28
+ const phase = getTagValue(scenario.tags, '@phase:');
29
+ const validation = getTagValue(scenario.tags, '@validation:');
30
+ const req = getTagValue(scenario.tags, '@req:');
31
+ const hasRiskTag = scenario.tags.some((tag) => riskTags.has(tag.slice(1)));
32
+
33
+ if (!phase) {
34
+ errors.push(`${label} missing @phase:<phase-id>`);
35
+ }
36
+
37
+ if (!validation) {
38
+ errors.push(`${label} missing @validation:<type>`);
39
+ } else if (!allowedValidationTypes.has(validation)) {
40
+ errors.push(`${label} has invalid @validation:${validation}`);
41
+ }
42
+
43
+ if (!req) {
44
+ errors.push(`${label} missing @req:<requirement-id>`);
45
+ }
46
+
47
+ if (!hasPriority(scenario.tags)) {
48
+ errors.push(`${label} missing @p0/@p1/@p2`);
49
+ }
50
+
51
+ if (scenario.tags.includes('@p0') && scenario.tags.includes('@gap-pending')) {
52
+ errors.push(`${label} cannot use @gap-pending on @p0 Scenario`);
53
+ }
54
+
55
+ if ((scenario.tags.includes('@p0') || scenario.tags.includes('@p1')) && !hasRiskTag) {
56
+ errors.push(`${label} has no risk/boundary tag`);
57
+ }
58
+ }
59
+
60
+ reportResult(`sdd:lint-features ${change}`, errors, warnings);
@@ -0,0 +1,41 @@
1
+ import {
2
+ getChangeName,
3
+ getScenarioPhases,
4
+ parseTaskPhases,
5
+ reportResult,
6
+ } from './lib.mjs';
7
+
8
+ const change = getChangeName();
9
+ const featurePhases = getScenarioPhases(change);
10
+ const taskPhases = parseTaskPhases(change);
11
+ const errors = [];
12
+
13
+ if (!featurePhases.size) {
14
+ errors.push(`no @phase tags found in feature Scenario tags for change "${change}"`);
15
+ }
16
+
17
+ for (const phase of featurePhases) {
18
+ const taskPhase = taskPhases.get(phase);
19
+ if (!taskPhase) {
20
+ errors.push(`feature phase "${phase}" is missing in tasks.md`);
21
+ continue;
22
+ }
23
+
24
+ const block = taskPhase.lines.join('\n');
25
+ if (!block.includes(`Covers: \`@phase:${phase}\``)) {
26
+ errors.push(`tasks phase "${phase}" missing coverage line: Covers: \`@phase:${phase}\``);
27
+ }
28
+
29
+ const acceptancePattern = new RegExp(`Acceptance 验证 .+@phase:${phase}`);
30
+ if (!acceptancePattern.test(block)) {
31
+ errors.push(`tasks phase "${phase}" missing acceptance validation task`);
32
+ }
33
+ }
34
+
35
+ for (const phase of taskPhases.keys()) {
36
+ if (!featurePhases.has(phase)) {
37
+ errors.push(`tasks phase "${phase}" has no matching feature @phase:${phase}`);
38
+ }
39
+ }
40
+
41
+ reportResult(`sdd:lint-tasks ${change}`, errors);
@@ -0,0 +1,185 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const repoRoot = process.cwd();
7
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sdd-self-test-'));
8
+ const checks = [];
9
+
10
+ function writeFile(file, content) {
11
+ fs.mkdirSync(path.dirname(file), { recursive: true });
12
+ fs.writeFileSync(file, content);
13
+ }
14
+
15
+ function createChange(name, featureTags, tasksPhase = 'alpha', includeAcceptance = true) {
16
+ const changeDir = path.join(tempRoot, 'openspec', 'changes', name);
17
+ writeFile(
18
+ path.join(changeDir, 'specs', 'fixture', 'features', 'fixture.feature'),
19
+ [
20
+ '@fixture',
21
+ 'Feature: Fixture',
22
+ '',
23
+ ' Rule: Fixture rule',
24
+ '',
25
+ ` ${featureTags}`,
26
+ ' Scenario: Fixture scenario',
27
+ ' Given 存在测试前置条件',
28
+ ' When 用户执行测试动作',
29
+ ' Then 系统展示测试结果',
30
+ '',
31
+ ].join('\n'),
32
+ );
33
+
34
+ const acceptance = includeAcceptance
35
+ ? [`- [ ] Acceptance 验证 \`@phase:${tasksPhase}\` 全部自动化 Scenario`]
36
+ : [];
37
+
38
+ writeFile(
39
+ path.join(changeDir, 'tasks.md'),
40
+ [
41
+ '# Tasks',
42
+ '',
43
+ `## Phase: ${tasksPhase}`,
44
+ '',
45
+ `Covers: \`@phase:${tasksPhase}\``,
46
+ '',
47
+ '- [ ] 实现 fixture',
48
+ ...acceptance,
49
+ '',
50
+ ].join('\n'),
51
+ );
52
+
53
+ return changeDir;
54
+ }
55
+
56
+ function writeReport(change, phase, gate = 'passed', overrides = {}) {
57
+ const values = {
58
+ selected: 1,
59
+ automated: 1,
60
+ passed: 1,
61
+ mismatch: 0,
62
+ blocked: 0,
63
+ manual: 0,
64
+ 'gap-pending': 0,
65
+ ...overrides,
66
+ };
67
+
68
+ writeFile(
69
+ path.join(tempRoot, 'specs', 'e2e', 'bdd', change, `validation-report.${phase}.md`),
70
+ [
71
+ '# Validation Report',
72
+ '',
73
+ `selected: ${values.selected}`,
74
+ `automated: ${values.automated}`,
75
+ `passed: ${values.passed}`,
76
+ `mismatch: ${values.mismatch}`,
77
+ `blocked: ${values.blocked}`,
78
+ `manual: ${values.manual}`,
79
+ `gap-pending: ${values['gap-pending']}`,
80
+ `gate: ${gate}`,
81
+ '',
82
+ ].join('\n'),
83
+ );
84
+ }
85
+
86
+ function runScript(script, args) {
87
+ return spawnSync(process.execPath, [path.join(repoRoot, script), ...args], {
88
+ cwd: tempRoot,
89
+ encoding: 'utf8',
90
+ });
91
+ }
92
+
93
+ function expectExit(name, result, expected) {
94
+ const actual = result.status ?? 0;
95
+ checks.push({ name, actual, expected, stdout: result.stdout, stderr: result.stderr });
96
+ }
97
+
98
+ try {
99
+ createChange('valid', '@phase:alpha @validation:unit @req:fixture @p0 @state-machine');
100
+ expectExit('valid feature lint passes', runScript('scripts/sdd/lint-features.mjs', ['valid']), 0);
101
+ expectExit('valid task lint passes', runScript('scripts/sdd/lint-tasks.mjs', ['valid']), 0);
102
+
103
+ createChange('missing-phase', '@validation:unit @req:fixture @p0 @state-machine');
104
+ expectExit('missing phase fails feature lint', runScript('scripts/sdd/lint-features.mjs', ['missing-phase']), 1);
105
+
106
+ createChange('invalid-validation', '@phase:alpha @validation:unknown @req:fixture @p0 @state-machine');
107
+ expectExit('invalid validation fails feature lint', runScript('scripts/sdd/lint-features.mjs', ['invalid-validation']), 1);
108
+
109
+ createChange('p0-gap', '@phase:alpha @validation:unit @req:fixture @p0 @gap-pending @state-machine');
110
+ expectExit('p0 gap-pending fails feature lint', runScript('scripts/sdd/lint-features.mjs', ['p0-gap']), 1);
111
+
112
+ createChange('phase-mismatch', '@phase:alpha @validation:unit @req:fixture @p0 @state-machine', 'beta');
113
+ expectExit('phase mismatch fails task lint', runScript('scripts/sdd/lint-tasks.mjs', ['phase-mismatch']), 1);
114
+
115
+ createChange('missing-acceptance', '@phase:alpha @validation:unit @req:fixture @p0 @state-machine', 'alpha', false);
116
+ expectExit('missing acceptance fails task lint', runScript('scripts/sdd/lint-tasks.mjs', ['missing-acceptance']), 1);
117
+
118
+ createChange('report-valid', '@phase:alpha @validation:unit @req:fixture @p0 @state-machine');
119
+ writeReport('report-valid', 'alpha', 'passed');
120
+ expectExit('passed report passes phase gate', runScript('scripts/sdd/check-report.mjs', ['report-valid', 'alpha']), 0);
121
+ expectExit('passed report summarizes final acceptance', runScript('scripts/sdd/summarize-acceptance.mjs', ['report-valid']), 0);
122
+
123
+ createChange('report-undercovers', '@phase:alpha @validation:unit @req:fixture @p0 @state-machine');
124
+ writeFile(
125
+ path.join(
126
+ tempRoot,
127
+ 'openspec',
128
+ 'changes',
129
+ 'report-undercovers',
130
+ 'specs',
131
+ 'fixture',
132
+ 'features',
133
+ 'fixture.feature',
134
+ ),
135
+ [
136
+ '@fixture',
137
+ 'Feature: Fixture',
138
+ '',
139
+ ' Rule: Fixture rule',
140
+ '',
141
+ ' @phase:alpha @validation:unit @req:fixture @p0 @state-machine',
142
+ ' Scenario: Fixture scenario one',
143
+ ' Given 存在测试前置条件',
144
+ ' When 用户执行测试动作',
145
+ ' Then 系统展示测试结果',
146
+ '',
147
+ ' @phase:alpha @validation:unit @req:fixture @p1 @degradation',
148
+ ' Scenario: Fixture scenario two',
149
+ ' Given 存在测试前置条件',
150
+ ' When 用户执行测试动作',
151
+ ' Then 系统展示测试结果',
152
+ '',
153
+ ].join('\n'),
154
+ );
155
+ writeReport('report-undercovers', 'alpha', 'passed');
156
+ expectExit(
157
+ 'undercovered report fails phase gate',
158
+ runScript('scripts/sdd/check-report.mjs', ['report-undercovers', 'alpha']),
159
+ 1,
160
+ );
161
+
162
+ createChange('report-failed', '@phase:alpha @validation:unit @req:fixture @p0 @state-machine');
163
+ writeReport('report-failed', 'alpha', 'failed');
164
+ expectExit('failed report fails phase gate', runScript('scripts/sdd/check-report.mjs', ['report-failed', 'alpha']), 1);
165
+
166
+ createChange('report-blocked', '@phase:alpha @validation:unit @req:fixture @p0 @state-machine');
167
+ writeReport('report-blocked', 'alpha', 'passed', { blocked: 1, passed: 0 });
168
+ expectExit('blocked report fails phase gate', runScript('scripts/sdd/check-report.mjs', ['report-blocked', 'alpha']), 1);
169
+
170
+ const failures = checks.filter((check) => check.actual !== check.expected);
171
+ if (failures.length) {
172
+ failures.forEach((failure) => {
173
+ console.error(`FAILED: ${failure.name}`);
174
+ console.error(`expected exit ${failure.expected}, got ${failure.actual}`);
175
+ if (failure.stdout) console.error(failure.stdout);
176
+ if (failure.stderr) console.error(failure.stderr);
177
+ });
178
+ process.exitCode = 1;
179
+ } else {
180
+ checks.forEach((check) => console.log(`ok - ${check.name}`));
181
+ console.log(`sdd:self-test passed (${checks.length} checks)`);
182
+ }
183
+ } finally {
184
+ fs.rmSync(tempRoot, { recursive: true, force: true });
185
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getChangeDir, getChangeName, parseTaskPhases, reportResult, validatePhaseReport } from './lib.mjs';
4
+
5
+ const change = getChangeName();
6
+ const phases = [...parseTaskPhases(change).keys()];
7
+ const reportDir = path.join(process.cwd(), 'specs', 'e2e', 'bdd', change);
8
+ const errors = [];
9
+ const rows = [];
10
+
11
+ for (const phase of phases) {
12
+ const reportPath = path.join(reportDir, `validation-report.${phase}.md`);
13
+ if (!fs.existsSync(reportPath)) {
14
+ errors.push(`missing phase report: ${reportPath}`);
15
+ rows.push(`| \`${phase}\` | missing | - |`);
16
+ continue;
17
+ }
18
+
19
+ const validation = validatePhaseReport(change, phase, reportPath);
20
+ const gate = validation.errors.length ? 'failed' : 'passed';
21
+ errors.push(...validation.errors.map((error) => `phase ${phase}: ${error}`));
22
+ rows.push(`| \`${phase}\` | ${gate} | \`${path.relative(process.cwd(), reportPath)}\` |`);
23
+ }
24
+
25
+ const summaryPath = path.join(getChangeDir(change), 'acceptance-coverage.md');
26
+ const summary = [
27
+ '# Acceptance Coverage',
28
+ '',
29
+ `Change: \`${change}\``,
30
+ '',
31
+ '| Phase | Gate | Report |',
32
+ '|---|---|---|',
33
+ ...rows,
34
+ '',
35
+ `Final gate: ${errors.length ? 'failed' : 'passed'}`,
36
+ '',
37
+ ].join('\n');
38
+
39
+ fs.writeFileSync(summaryPath, summary);
40
+
41
+ reportResult(`sdd:acceptance ${change}`, errors);
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "openspec-sdd-e2e-kit",
3
+ "version": "0.1.0",
4
+ "description": "Project-local OpenSpec SDD + E2E workflow kit",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "sdd-e2e-kit": "bin/sdd-e2e-kit.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "kit",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --check src/lib.mjs && node --check src/check.mjs && node --check src/diff.mjs && node --check src/install.mjs && node --check bin/sdd-e2e-kit.mjs && cd kit && node scripts/sdd/self-test.mjs"
18
+ }
19
+ }