musubi-sdd 2.2.0 → 3.0.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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Converters Module
3
+ *
4
+ * Cross-format conversion between MUSUBI and Spec Kit
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { parseMusubiProject } = require('./parsers/musubi-parser');
10
+ const { parseSpeckitProject } = require('./parsers/speckit-parser');
11
+ const { writeMusubiProject } = require('./writers/musubi-writer');
12
+ const { writeSpeckitProject } = require('./writers/speckit-writer');
13
+ const irTypes = require('./ir/types');
14
+
15
+ /**
16
+ * Convert a Spec Kit project to MUSUBI format
17
+ * @param {string} sourcePath - Path to Spec Kit project
18
+ * @param {Object} options - Conversion options
19
+ * @returns {Promise<{filesConverted: number, warnings: string[], outputPath: string}>}
20
+ */
21
+ async function convertFromSpeckit(sourcePath, options = {}) {
22
+ const { output = '.', dryRun = false, force = false, verbose = false, preserveRaw = false } = options;
23
+
24
+ if (verbose) console.log(`Converting Spec Kit project from: ${sourcePath}`);
25
+
26
+ // Parse Spec Kit project to IR
27
+ const ir = await parseSpeckitProject(sourcePath);
28
+
29
+ if (verbose) {
30
+ console.log(` Found ${ir.features.length} features`);
31
+ console.log(` Found ${ir.constitution.articles.length} constitution articles`);
32
+ }
33
+
34
+ // Write to MUSUBI format
35
+ const result = await writeMusubiProject(ir, output, { dryRun, force, preserveRaw, verbose });
36
+
37
+ return {
38
+ filesConverted: result.filesWritten,
39
+ warnings: result.warnings,
40
+ outputPath: output,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Convert a MUSUBI project to Spec Kit format
46
+ * @param {Object} options - Conversion options
47
+ * @returns {Promise<{filesConverted: number, warnings: string[], outputPath: string}>}
48
+ */
49
+ async function convertToSpeckit(options = {}) {
50
+ const {
51
+ source = '.',
52
+ output = './.specify',
53
+ dryRun = false,
54
+ force = false,
55
+ verbose = false,
56
+ preserveRaw = false
57
+ } = options;
58
+
59
+ if (verbose) console.log(`Converting MUSUBI project to Spec Kit format`);
60
+
61
+ // Parse MUSUBI project to IR
62
+ const ir = await parseMusubiProject(source);
63
+
64
+ if (verbose) {
65
+ console.log(` Found ${ir.features.length} features`);
66
+ console.log(` Found ${ir.constitution.articles.length} constitution articles`);
67
+ }
68
+
69
+ // Write to Spec Kit format
70
+ const result = await writeSpeckitProject(ir, output.replace('/.specify', ''), { dryRun, force, preserveRaw, verbose });
71
+
72
+ return {
73
+ filesConverted: result.filesWritten,
74
+ warnings: result.warnings,
75
+ outputPath: output,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Validate a project format
81
+ * @param {string} format - 'speckit' or 'musubi'
82
+ * @param {string} projectPath - Path to project
83
+ * @returns {Promise<{valid: boolean, errors: string[], warnings: string[]}>}
84
+ */
85
+ async function validateFormat(format, projectPath) {
86
+ const errors = [];
87
+ const warnings = [];
88
+
89
+ try {
90
+ if (format === 'speckit') {
91
+ await parseSpeckitProject(projectPath);
92
+ } else if (format === 'musubi') {
93
+ await parseMusubiProject(projectPath);
94
+ } else {
95
+ errors.push(`Unknown format: ${format}. Use 'speckit' or 'musubi'.`);
96
+ }
97
+ } catch (error) {
98
+ errors.push(error.message);
99
+ }
100
+
101
+ return {
102
+ valid: errors.length === 0,
103
+ errors,
104
+ warnings,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Test roundtrip conversion (A → B → A')
110
+ * @param {string} projectPath - Path to project
111
+ * @param {Object} options - Test options
112
+ * @returns {Promise<{passed: boolean, similarity: number, differences: string[]}>}
113
+ */
114
+ async function testRoundtrip(projectPath, options = {}) {
115
+ const { verbose = false } = options;
116
+ const differences = [];
117
+
118
+ try {
119
+ // Detect format
120
+ const fs = require('fs-extra');
121
+ const path = require('path');
122
+
123
+ const isSpeckit = await fs.pathExists(path.join(projectPath, '.specify'));
124
+ const isMusubi = await fs.pathExists(path.join(projectPath, 'steering'));
125
+
126
+ if (!isSpeckit && !isMusubi) {
127
+ return {
128
+ passed: false,
129
+ similarity: 0,
130
+ differences: ['Could not detect project format (neither .specify nor steering directory found)'],
131
+ };
132
+ }
133
+
134
+ if (verbose) {
135
+ console.log(`Detected format: ${isSpeckit ? 'Spec Kit' : 'MUSUBI'}`);
136
+ }
137
+
138
+ // Parse original
139
+ const originalIR = isSpeckit
140
+ ? await parseSpeckitProject(projectPath)
141
+ : await parseMusubiProject(projectPath);
142
+
143
+ // Convert to other format (in memory)
144
+ const tempDir = path.join(projectPath, '.roundtrip-temp');
145
+ await fs.ensureDir(tempDir);
146
+
147
+ try {
148
+ // Write to other format
149
+ if (isSpeckit) {
150
+ await writeMusubiProject(originalIR, tempDir, { force: true });
151
+ } else {
152
+ await writeSpeckitProject(originalIR, tempDir, { force: true });
153
+ }
154
+
155
+ // Parse converted
156
+ const convertedIR = isSpeckit
157
+ ? await parseMusubiProject(tempDir)
158
+ : await parseSpeckitProject(tempDir);
159
+
160
+ // Write back to original format
161
+ const tempDir2 = path.join(projectPath, '.roundtrip-temp2');
162
+ await fs.ensureDir(tempDir2);
163
+
164
+ if (isSpeckit) {
165
+ await writeSpeckitProject(convertedIR, tempDir2, { force: true });
166
+ } else {
167
+ await writeMusubiProject(convertedIR, tempDir2, { force: true });
168
+ }
169
+
170
+ // Parse roundtrip result
171
+ const roundtripIR = isSpeckit
172
+ ? await parseSpeckitProject(tempDir2)
173
+ : await parseMusubiProject(tempDir2);
174
+
175
+ // Compare
176
+ const similarity = compareIR(originalIR, roundtripIR, differences);
177
+
178
+ // Cleanup
179
+ await fs.remove(tempDir);
180
+ await fs.remove(tempDir2);
181
+
182
+ return {
183
+ passed: similarity >= 90,
184
+ similarity,
185
+ differences,
186
+ };
187
+ } finally {
188
+ // Ensure cleanup
189
+ await fs.remove(tempDir).catch(() => {});
190
+ await fs.remove(path.join(projectPath, '.roundtrip-temp2')).catch(() => {});
191
+ }
192
+ } catch (error) {
193
+ return {
194
+ passed: false,
195
+ similarity: 0,
196
+ differences: [`Roundtrip test error: ${error.message}`],
197
+ };
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Compare two IR structures and return similarity percentage
203
+ * @param {import('./ir/types').ProjectIR} original
204
+ * @param {import('./ir/types').ProjectIR} roundtrip
205
+ * @param {string[]} differences
206
+ * @returns {number} Similarity percentage (0-100)
207
+ */
208
+ function compareIR(original, roundtrip, differences) {
209
+ let matches = 0;
210
+ let total = 0;
211
+
212
+ // Compare metadata
213
+ total++;
214
+ if (original.metadata.name === roundtrip.metadata.name) {
215
+ matches++;
216
+ } else {
217
+ differences.push(`Name mismatch: "${original.metadata.name}" vs "${roundtrip.metadata.name}"`);
218
+ }
219
+
220
+ // Compare features count
221
+ total++;
222
+ if (original.features.length === roundtrip.features.length) {
223
+ matches++;
224
+ } else {
225
+ differences.push(`Feature count mismatch: ${original.features.length} vs ${roundtrip.features.length}`);
226
+ }
227
+
228
+ // Compare each feature
229
+ for (let i = 0; i < Math.min(original.features.length, roundtrip.features.length); i++) {
230
+ const origFeature = original.features[i];
231
+ const rtFeature = roundtrip.features[i];
232
+
233
+ // Compare feature name
234
+ total++;
235
+ if (origFeature.name === rtFeature.name) {
236
+ matches++;
237
+ } else {
238
+ differences.push(`Feature ${i} name mismatch: "${origFeature.name}" vs "${rtFeature.name}"`);
239
+ }
240
+
241
+ // Compare requirements count
242
+ total++;
243
+ const origReqs = origFeature.specification?.requirements?.length || 0;
244
+ const rtReqs = rtFeature.specification?.requirements?.length || 0;
245
+ if (origReqs === rtReqs) {
246
+ matches++;
247
+ } else {
248
+ differences.push(`Feature ${i} requirements count mismatch: ${origReqs} vs ${rtReqs}`);
249
+ }
250
+
251
+ // Compare tasks count
252
+ total++;
253
+ const origTasks = origFeature.tasks?.length || 0;
254
+ const rtTasks = rtFeature.tasks?.length || 0;
255
+ if (origTasks === rtTasks) {
256
+ matches++;
257
+ } else {
258
+ differences.push(`Feature ${i} tasks count mismatch: ${origTasks} vs ${rtTasks}`);
259
+ }
260
+ }
261
+
262
+ // Compare constitution articles
263
+ total++;
264
+ const origArticles = original.constitution?.articles?.length || 0;
265
+ const rtArticles = roundtrip.constitution?.articles?.length || 0;
266
+ if (origArticles === rtArticles) {
267
+ matches++;
268
+ } else {
269
+ differences.push(`Constitution articles count mismatch: ${origArticles} vs ${rtArticles}`);
270
+ }
271
+
272
+ return Math.round((matches / total) * 100);
273
+ }
274
+
275
+ module.exports = {
276
+ convertFromSpeckit,
277
+ convertToSpeckit,
278
+ validateFormat,
279
+ testRoundtrip,
280
+ parseMusubiProject,
281
+ parseSpeckitProject,
282
+ writeMusubiProject,
283
+ writeSpeckitProject,
284
+ ir: irTypes,
285
+ };