opsx-gherkin 1.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/CONTRIBUTING.md +53 -0
  3. package/LICENSE +21 -0
  4. package/README.md +99 -0
  5. package/SECURITY.md +14 -0
  6. package/dist/cli.mjs +50 -0
  7. package/dist/cli.mjs.map +1 -0
  8. package/dist/converter-CFtzcfoM.mjs +977 -0
  9. package/dist/converter-CFtzcfoM.mjs.map +1 -0
  10. package/dist/mdg-to-opsx.mjs +8 -0
  11. package/dist/mdg-to-opsx.mjs.map +1 -0
  12. package/dist/official-99qBUZpr.mjs +91 -0
  13. package/dist/official-99qBUZpr.mjs.map +1 -0
  14. package/dist/opsx-to-feature.mjs +8 -0
  15. package/dist/opsx-to-feature.mjs.map +1 -0
  16. package/dist/opsx-to-mdg.mjs +8 -0
  17. package/dist/opsx-to-mdg.mjs.map +1 -0
  18. package/dist/validate-spec.mjs +56 -0
  19. package/dist/validate-spec.mjs.map +1 -0
  20. package/package.json +87 -0
  21. package/skills/opsx-gherkin/SKILL.md +144 -0
  22. package/skills/opsx-gherkin/references/OPENSPEC_MDG_PROFILE.feature.md +372 -0
  23. package/skills/opsx-gherkin/references/examples/account.feature +85 -0
  24. package/skills/opsx-gherkin/references/examples/account.feature.md +96 -0
  25. package/skills/opsx-gherkin/references/examples/account.openspec.md +109 -0
  26. package/skills/opsx-gherkin/references/examples/authentication-delta.feature +41 -0
  27. package/skills/opsx-gherkin/references/examples/authentication-delta.feature.md +45 -0
  28. package/skills/opsx-gherkin/references/examples/authentication-delta.openspec.md +52 -0
  29. package/skills/opsx-gherkin/references/examples/minimal.feature +17 -0
  30. package/skills/opsx-gherkin/references/examples/minimal.feature.md +19 -0
  31. package/skills/opsx-gherkin/references/examples/minimal.openspec.md +24 -0
  32. package/skills/opsx-gherkin/references/profile-access.feature.md +34 -0
@@ -0,0 +1,977 @@
1
+ import { a as validateOpenSpec, i as parseAndValidateMdg, l as isFsError, n as openSpecDiagnostics, r as parseAndValidateFeature, s as ConversionError, t as gherkinDiagnostics } from "./official-99qBUZpr.mjs";
2
+ import { link, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ //#region src/model.ts
6
+ /** Rename Rules are structural wrappers; their FROM and TO names carry the identity. */
7
+ function normalizedModel(model) {
8
+ return {
9
+ ...model,
10
+ rules: model.rules.map((rule) => ({
11
+ ...rule,
12
+ name: rule.operation === "RENAMED" ? "Rename requirement" : rule.name
13
+ }))
14
+ };
15
+ }
16
+ function assertPreserved(source, destination) {
17
+ if (!isDeepStrictEqual(normalizedModel(source), normalizedModel(destination))) throw new ConversionError("Conversion would change the normalized document model", ["The destination was not emitted. Check supported structure, ordering, and literal values."]);
18
+ }
19
+ //#endregion
20
+ //#region src/constants.ts
21
+ const OPERATION_TAGS = /* @__PURE__ */ new Map([
22
+ ["@openspec-added", "ADDED"],
23
+ ["@openspec-modified", "MODIFIED"],
24
+ ["@openspec-removed", "REMOVED"],
25
+ ["@openspec-renamed", "RENAMED"]
26
+ ]);
27
+ const RESERVED_PREFIXES = ["@openspec-", "@mdg-"];
28
+ const SYNTHETIC_TAG = "@mdg-feature-scenarios";
29
+ const DELTA_TAG = "@openspec-delta";
30
+ //#endregion
31
+ //#region src/syntax.ts
32
+ function pushDescriptionAndSteps(output, node, mdg = false) {
33
+ if (node.description) output.push(node.description, "");
34
+ for (const step of node.steps) {
35
+ const keyword = mdg ? titleCase(step.keyword) : `**${step.keyword}**`;
36
+ output.push(`${mdg ? "*" : "-"} ${keyword} ${step.text}`);
37
+ if (step.table) pushTable(output, step.table, false, !mdg);
38
+ if (step.docString) {
39
+ output.push("", ` \`\`\`${step.docString.mediaType || ""}`);
40
+ for (const line of step.docString.content.split("\n")) output.push(` ${line}`);
41
+ output.push(" ```");
42
+ }
43
+ }
44
+ output.push("");
45
+ }
46
+ function pushTable(output, rows, blankAfter = true, separator = true) {
47
+ if (!rows?.length) return;
48
+ output.push(` | ${rows[0].map(escapeCell).join(" | ")} |`);
49
+ if (separator) output.push(` | ${rows[0].map(() => "---").join(" | ")} |`);
50
+ for (const row of rows.slice(1)) output.push(` | ${row.map(escapeCell).join(" | ")} |`);
51
+ if (blankAfter) output.push("");
52
+ }
53
+ function parseTable(lines) {
54
+ const rows = lines.filter((line) => /^\s{2,}\|/.test(line)).map(parseTableRow);
55
+ if (rows.length >= 2 && rows[1].every((cell) => /^:?-+:?$/.test(cell))) rows.splice(1, 1);
56
+ if (!rows.length) throw new ConversionError("Empty table");
57
+ const width = rows[0].length;
58
+ if (rows.some((row) => row.length !== width)) throw new ConversionError("Inconsistent table column count");
59
+ return rows;
60
+ }
61
+ function parseTableRow(line) {
62
+ const content = line.trim().replace(/^\|/, "").replace(/\|$/, "");
63
+ const cells = [];
64
+ let cell = "";
65
+ let escaped = false;
66
+ for (const char of content) if (escaped) {
67
+ cell += char === "n" ? "\n" : char;
68
+ escaped = false;
69
+ } else if (char === "\\") escaped = true;
70
+ else if (char === "|") {
71
+ cells.push(cell.trim());
72
+ cell = "";
73
+ } else cell += char;
74
+ cells.push(cell.trim());
75
+ return cells;
76
+ }
77
+ function normalizeLines(text) {
78
+ return text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n");
79
+ }
80
+ function fenceMask(lines) {
81
+ const mask = [];
82
+ let fence = null;
83
+ for (let index = 0; index < lines.length; index += 1) {
84
+ const match = lines[index].match(/^\s*(`{3,}|~{3,})/);
85
+ mask[index] = Boolean(fence);
86
+ if (match && !fence) {
87
+ fence = match[1][0];
88
+ mask[index] = true;
89
+ } else if (match && fence === match[1][0]) {
90
+ mask[index] = true;
91
+ fence = null;
92
+ }
93
+ }
94
+ return mask;
95
+ }
96
+ function findHeading(lines, mask, level) {
97
+ for (let index = 0; index < lines.length; index += 1) {
98
+ const match = !mask[index] && lines[index].match(new RegExp(`^#{${level}}\\s+(.+?)\\s*$`));
99
+ if (match) return {
100
+ index,
101
+ title: match[1].trim()
102
+ };
103
+ }
104
+ return null;
105
+ }
106
+ function headingsInRange(lines, mask, level, start, end) {
107
+ const result = [];
108
+ for (let index = start; index < end; index += 1) {
109
+ const match = !mask[index] && lines[index].match(new RegExp(`^#{${level}}\\s+(.+?)\\s*$`));
110
+ if (match) result.push({
111
+ index,
112
+ title: match[1].trim()
113
+ });
114
+ }
115
+ return result;
116
+ }
117
+ function findSection(lines, mask, title) {
118
+ const section = findSectionOptional(lines, mask, title);
119
+ if (!section) throw new ConversionError(`Missing OpenSpec section: ## ${title}`);
120
+ return section;
121
+ }
122
+ function findSectionOptional(lines, mask, title) {
123
+ for (let index = 0; index < lines.length; index += 1) if (!mask[index] && new RegExp(`^##\\s+${escapeRegex(title)}\\s*$`, "i").test(lines[index])) {
124
+ let end = lines.length;
125
+ for (let next = index + 1; next < lines.length; next += 1) if (!mask[next] && /^##\s+/.test(lines[next])) {
126
+ end = next;
127
+ break;
128
+ }
129
+ return {
130
+ start: index,
131
+ end
132
+ };
133
+ }
134
+ return null;
135
+ }
136
+ function sectionContent(lines, mask, title) {
137
+ const section = findSectionOptional(lines, mask, title);
138
+ return section ? trimBlank(lines.slice(section.start + 1, section.end)).join("\n") : null;
139
+ }
140
+ function precedingTags(lines, headingIndex) {
141
+ let index = headingIndex - 1;
142
+ while (index >= 0 && !lines[index].trim()) index -= 1;
143
+ const blocks = [];
144
+ while (index >= 0 && isTagLine(lines[index])) {
145
+ blocks.unshift(...extractTags(lines[index]));
146
+ index -= 1;
147
+ while (index >= 0 && !lines[index].trim()) index -= 1;
148
+ }
149
+ return blocks;
150
+ }
151
+ function tagBlockStart(lines, headingIndex) {
152
+ let index = headingIndex - 1;
153
+ while (index >= 0 && !lines[index].trim()) index -= 1;
154
+ while (index >= 0 && isTagLine(lines[index])) {
155
+ index -= 1;
156
+ while (index >= 0 && !lines[index].trim()) index -= 1;
157
+ }
158
+ return index + 1;
159
+ }
160
+ function tagStartLine(lines, node) {
161
+ return tagBlockStart(lines, nodeLine(node) - 1) + 1;
162
+ }
163
+ function isTagLine(line) {
164
+ return [...line.matchAll(/`(@[^`]+)`/g)].length > 0 && line.replace(/`(@[^`]+)`/g, "").trim() === "";
165
+ }
166
+ function extractTags(line) {
167
+ return [...line.matchAll(/`(@[^`]+)`/g)].map((match) => match[1]);
168
+ }
169
+ function proseBetween(lines, startLine, endLine) {
170
+ const slice = lines.slice(Math.max(0, startLine - 1), Math.max(0, endLine - 1));
171
+ while (slice.length && (!slice[slice.length - 1].trim() || isTagLine(slice[slice.length - 1]))) slice.pop();
172
+ return trimBlank(slice).join("\n");
173
+ }
174
+ function nextSiblingLine(line, siblings, fallback) {
175
+ return siblings.map(nodeLine).filter((candidate) => candidate > line).sort((a, b) => a - b)[0] || fallback;
176
+ }
177
+ function nodeLine(child) {
178
+ if ("location" in child) return child.location.line;
179
+ const value = child.background ?? child.scenario ?? child.rule ?? child.examples;
180
+ if (!value) throw new ConversionError("Missing Gherkin node location");
181
+ return value.location.line;
182
+ }
183
+ function trimBlank(lines) {
184
+ const copy = [...lines];
185
+ while (copy.length && !copy[0].trim()) copy.shift();
186
+ while (copy.length && !copy[copy.length - 1].trim()) copy.pop();
187
+ return copy;
188
+ }
189
+ function pushTags(output, tags) {
190
+ if (tags?.length) output.push(tags.map((tag) => `\`${tag}\``).join(" "));
191
+ }
192
+ function resolveOpenSpecName(text, options) {
193
+ const lines = normalizeLines(text);
194
+ const h1 = findHeading(lines, fenceMask(lines), 1);
195
+ if (h1) return h1.title.replace(/^Feature:\s*/i, "").trim();
196
+ if (options.name) return options.name;
197
+ if (options.sourcePath) {
198
+ const parent = basename(dirname(options.sourcePath));
199
+ if (parent && parent !== ".") return parent;
200
+ }
201
+ throw new ConversionError("OpenSpec text without an H1 title requires --name");
202
+ }
203
+ function slugify(value) {
204
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "spec";
205
+ }
206
+ function titleCase(keyword) {
207
+ return keyword[0] + keyword.slice(1).toLowerCase();
208
+ }
209
+ function escapeCell(value) {
210
+ return String(value).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n/g, "\\n");
211
+ }
212
+ function escapeRegex(value) {
213
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
214
+ }
215
+ //#endregion
216
+ //#region src/parsers/mdg.ts
217
+ function parseMdgModel(text, document) {
218
+ const feature = document.feature;
219
+ if (!feature) throw new ConversionError("Gherkin document has no Feature");
220
+ const lines = normalizeLines(text);
221
+ const featureChildren = [...feature.children].sort((a, b) => nodeLine(a) - nodeLine(b));
222
+ const firstFeatureChild = featureChildren[0] ? tagStartLine(lines, featureChildren[0]) : lines.length + 1;
223
+ const description = proseBetween(lines, feature.location.line + 1, firstFeatureChild);
224
+ const featureTags = feature.tags.map((tag) => tag.name);
225
+ const isDelta = featureTags.includes(DELTA_TAG);
226
+ const model = {
227
+ kind: isDelta ? "delta" : "main",
228
+ name: feature.name.trim(),
229
+ description,
230
+ tags: featureTags.filter((tag) => tag !== DELTA_TAG),
231
+ background: null,
232
+ featureScenarios: [],
233
+ rules: []
234
+ };
235
+ for (let index = 0; index < featureChildren.length; index += 1) {
236
+ const child = featureChildren[index];
237
+ const boundary = featureChildren[index + 1] ? tagStartLine(lines, featureChildren[index + 1]) : lines.length + 1;
238
+ if (child.background) model.background = mapMdgBackground(child.background, lines, featureChildren);
239
+ if (child.scenario) model.featureScenarios.push(mapMdgScenario(child.scenario, lines));
240
+ if (child.rule) model.rules.push(mapMdgRule(child.rule, lines, isDelta, boundary));
241
+ }
242
+ return model;
243
+ }
244
+ function mapMdgRule(rule, lines, isDelta, boundary) {
245
+ const children = [...rule.children].sort((a, b) => nodeLine(a) - nodeLine(b));
246
+ const firstChild = children[0] ? tagStartLine(lines, children[0]) : lines.length + 1;
247
+ const tags = rule.tags.map((tag) => tag.name);
248
+ const operationTags = tags.filter((tag) => OPERATION_TAGS.has(tag));
249
+ let operation;
250
+ if (isDelta) {
251
+ if (operationTags.length !== 1) throw new ConversionError(`Delta Rule "${rule.name}" must have exactly one @openspec operation tag`);
252
+ operation = OPERATION_TAGS.get(operationTags[0]);
253
+ } else if (operationTags.length) throw new ConversionError(`Main-spec Rule "${rule.name}" cannot use OpenSpec delta operation tags`);
254
+ const description = proseBetween(lines, rule.location.line + 1, children.length ? firstChild : boundary);
255
+ const mapped = {
256
+ name: rule.name.trim(),
257
+ description,
258
+ tags: tags.filter((tag) => !OPERATION_TAGS.has(tag)),
259
+ operation,
260
+ rename: null,
261
+ background: null,
262
+ scenarios: []
263
+ };
264
+ for (const child of children) {
265
+ if (child.background) mapped.background = mapMdgBackground(child.background, lines, children);
266
+ if (child.scenario) mapped.scenarios.push(mapMdgScenario(child.scenario, lines));
267
+ }
268
+ if (operation === "RENAMED") {
269
+ const from = description.match(/^FROM:\s*`### Requirement:\s*(.+?)`\s*$/im)?.[1];
270
+ const to = description.match(/^TO:\s*`### Requirement:\s*(.+?)`\s*$/im)?.[1];
271
+ if (!from || !to) throw new ConversionError(`RENAMED Rule "${rule.name}" requires FROM and TO lines`);
272
+ if (description.replace(/^FROM:.*$/gim, "").replace(/^TO:.*$/gim, "").trim() || mapped.background || mapped.scenarios.length) throw new ConversionError("RENAMED Rule contains unsupported content");
273
+ mapped.rename = {
274
+ from: from.trim(),
275
+ to: to.trim()
276
+ };
277
+ mapped.description = "";
278
+ }
279
+ return mapped;
280
+ }
281
+ function mapMdgBackground(background, lines, siblings) {
282
+ const firstStep = background.steps[0]?.location.line || nextSiblingLine(background.location.line, siblings, lines.length + 1);
283
+ return {
284
+ name: background.name.trim(),
285
+ description: proseBetween(lines, background.location.line + 1, firstStep),
286
+ steps: background.steps.map(mapMdgStep)
287
+ };
288
+ }
289
+ function mapMdgScenario(scenario, lines) {
290
+ const firstContent = Math.min(scenario.steps[0]?.location.line || Infinity, scenario.examples[0] ? tagStartLine(lines, { examples: scenario.examples[0] }) : Infinity);
291
+ return {
292
+ kind: /outline/i.test(scenario.keyword) ? "outline" : "scenario",
293
+ name: scenario.name.trim(),
294
+ description: proseBetween(lines, scenario.location.line + 1, Number.isFinite(firstContent) ? firstContent : lines.length + 1),
295
+ tags: scenario.tags.map((tag) => tag.name),
296
+ steps: scenario.steps.map(mapMdgStep),
297
+ examples: scenario.examples.map((examples) => mapMdgExamples(examples, lines))
298
+ };
299
+ }
300
+ function mapMdgExamples(examples, lines) {
301
+ const tableLine = examples.tableHeader?.location.line || lines.length + 1;
302
+ const rows = [];
303
+ if (examples.tableHeader) rows.push(examples.tableHeader.cells.map((cell) => cell.value));
304
+ rows.push(...examples.tableBody.map((row) => row.cells.map((cell) => cell.value)));
305
+ return {
306
+ name: examples.name.trim(),
307
+ description: proseBetween(lines, examples.location.line + 1, tableLine),
308
+ tags: examples.tags.map((tag) => tag.name),
309
+ rows
310
+ };
311
+ }
312
+ function mapMdgStep(step) {
313
+ return {
314
+ keyword: step.keyword.trim().toUpperCase(),
315
+ text: step.text,
316
+ table: step.dataTable ? step.dataTable.rows.map((row) => row.cells.map((cell) => cell.value)) : null,
317
+ docString: step.docString ? {
318
+ mediaType: step.docString.mediaType || "",
319
+ content: step.docString.content
320
+ } : null
321
+ };
322
+ }
323
+ //#endregion
324
+ //#region src/parsers/feature.ts
325
+ function description(value) {
326
+ const prefix = " ".repeat((value.location.column ?? 1) + 1);
327
+ return trimBlank(value.description.split("\n").map((line) => line.startsWith(prefix) ? line.slice(prefix.length) : line)).join("\n");
328
+ }
329
+ function background(value) {
330
+ return {
331
+ name: value.name.trim(),
332
+ description: description(value),
333
+ steps: value.steps.map(mapMdgStep)
334
+ };
335
+ }
336
+ function scenario(value) {
337
+ return {
338
+ ...background(value),
339
+ kind: /outline/i.test(value.keyword) ? "outline" : "scenario",
340
+ tags: value.tags.map((tag) => tag.name),
341
+ examples: value.examples.map((group) => ({
342
+ name: group.name.trim(),
343
+ description: description(group),
344
+ tags: group.tags.map((tag) => tag.name),
345
+ rows: [...group.tableHeader ? [group.tableHeader.cells.map((cell) => cell.value)] : [], ...group.tableBody.map((row) => row.cells.map((cell) => cell.value))]
346
+ }))
347
+ };
348
+ }
349
+ function parseFeatureModel(document) {
350
+ const feature = document.feature;
351
+ if (!feature) throw new ConversionError("Gherkin document has no Feature");
352
+ const tags = feature.tags.map((tag) => tag.name);
353
+ const model = {
354
+ name: feature.name.trim(),
355
+ description: description(feature),
356
+ kind: tags.includes("@openspec-delta") ? "delta" : "main",
357
+ tags: tags.filter((tag) => tag !== DELTA_TAG),
358
+ background: null,
359
+ featureScenarios: [],
360
+ rules: []
361
+ };
362
+ for (const child of feature.children) {
363
+ if (child.background) model.background = background(child.background);
364
+ if (child.scenario) model.featureScenarios.push(scenario(child.scenario));
365
+ if (child.rule) {
366
+ const value = child.rule;
367
+ const tags = value.tags.map((tag) => tag.name);
368
+ const operationTag = tags.find((tag) => OPERATION_TAGS.has(tag));
369
+ const rule = {
370
+ name: value.name.trim(),
371
+ description: description(value),
372
+ tags: tags.filter((tag) => !OPERATION_TAGS.has(tag)),
373
+ operation: operationTag ? OPERATION_TAGS.get(operationTag) : void 0,
374
+ rename: null,
375
+ background: null,
376
+ scenarios: []
377
+ };
378
+ for (const item of value.children) {
379
+ if (item.background) rule.background = background(item.background);
380
+ if (item.scenario) rule.scenarios.push(scenario(item.scenario));
381
+ }
382
+ if (rule.operation === "RENAMED") {
383
+ const from = rule.description.match(/^FROM:\s*### Requirement:\s*(.+)$/m)?.[1];
384
+ const to = rule.description.match(/^TO:\s*### Requirement:\s*(.+)$/m)?.[1];
385
+ if (!from || !to) throw new ConversionError("RENAMED Rule requires FROM and TO");
386
+ rule.rename = {
387
+ from: from.trim(),
388
+ to: to.trim()
389
+ };
390
+ rule.description = "";
391
+ }
392
+ model.rules.push(rule);
393
+ }
394
+ }
395
+ return model;
396
+ }
397
+ //#endregion
398
+ //#region src/parsers/openspec.ts
399
+ function detectOpenSpecKind(text) {
400
+ const lines = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n");
401
+ const mask = fenceMask(lines);
402
+ const main = lines.some((line, index) => !mask[index] && /^##\s+Requirements\s*$/i.test(line));
403
+ const delta = lines.some((line, index) => !mask[index] && /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED) Requirements\s*$/i.test(line));
404
+ if (main && delta) throw new ConversionError("OpenSpec input mixes main and delta requirement sections");
405
+ if (!main && !delta) throw new ConversionError("OpenSpec input has no Requirements or delta sections");
406
+ return delta ? "delta" : "main";
407
+ }
408
+ function parseOpenSpecModel(text, { kind, name }) {
409
+ const lines = normalizeLines(text);
410
+ const mask = fenceMask(lines);
411
+ const h1 = findHeading(lines, mask, 1);
412
+ const title = h1 ? h1.title : name;
413
+ const tags = h1 ? precedingTags(lines, h1.index) : [];
414
+ const model = {
415
+ kind,
416
+ name: title,
417
+ description: sectionContent(lines, mask, "Purpose")?.trim() || "",
418
+ tags: tags.filter((tag) => tag !== DELTA_TAG),
419
+ background: null,
420
+ featureScenarios: [],
421
+ rules: []
422
+ };
423
+ if (kind === "main") parseMainOpenSpec(lines, mask, model);
424
+ else parseDeltaOpenSpec(lines, mask, model);
425
+ return model;
426
+ }
427
+ function parseMainOpenSpec(lines, mask, model) {
428
+ const section = findSection(lines, mask, "Requirements");
429
+ const requirements = headingsInRange(lines, mask, 3, section.start + 1, section.end).filter((heading) => /^Requirement:\s*/i.test(heading.title));
430
+ const firstRequirementStart = requirements[0] ? tagBlockStart(lines, requirements[0].index) : section.end;
431
+ const preamble = trimBlank(lines.slice(section.start + 1, firstRequirementStart));
432
+ if (preamble.length) model.background = parseOpenSpecBackground(preamble, "feature");
433
+ for (let index = 0; index < requirements.length; index += 1) {
434
+ const heading = requirements[index];
435
+ const rule = parseOpenSpecRequirement(lines, mask, heading, index + 1 < requirements.length ? tagBlockStart(lines, requirements[index + 1].index) : section.end);
436
+ if (rule.tags.includes("@mdg-feature-scenarios")) {
437
+ rule.tags = rule.tags.filter((tag) => tag !== SYNTHETIC_TAG);
438
+ if (rule.background && !model.background) model.background = rule.background;
439
+ model.featureScenarios.push(...rule.scenarios);
440
+ } else model.rules.push(rule);
441
+ }
442
+ }
443
+ function parseDeltaOpenSpec(lines, mask, model) {
444
+ model.tags = model.tags.filter((tag) => tag !== DELTA_TAG);
445
+ const operations = headingsInRange(lines, mask, 2, 0, lines.length).flatMap((heading) => {
446
+ const match = heading.title.match(/^(ADDED|MODIFIED|REMOVED|RENAMED) Requirements$/i);
447
+ return match ? [match[1].toUpperCase()] : [];
448
+ });
449
+ if (new Set(operations).size !== operations.length) throw new ConversionError("Repeated delta operation section is unsupported");
450
+ for (const operation of operations) {
451
+ const section = findSectionOptional(lines, mask, `${operation} Requirements`);
452
+ if (!section) continue;
453
+ if (operation === "RENAMED") {
454
+ for (let index = section.start + 1; index < section.end; index += 1) {
455
+ const from = lines[index].match(/^\s*-?\s*FROM:\s*`?### Requirement:\s*(.+?)`?\s*$/i);
456
+ if (!from) continue;
457
+ let toIndex = index + 1;
458
+ while (toIndex < section.end && !lines[toIndex].trim()) toIndex += 1;
459
+ const to = lines[toIndex]?.match(/^\s*-?\s*TO:\s*`?### Requirement:\s*(.+?)`?\s*$/i);
460
+ if (!to) throw new ConversionError(`RENAMED entry for "${from[1].trim()}" has no TO line`);
461
+ model.rules.push({
462
+ name: "Rename requirement",
463
+ description: "",
464
+ tags: precedingTags(lines, index),
465
+ operation,
466
+ rename: {
467
+ from: from[1].trim(),
468
+ to: to[1].trim()
469
+ },
470
+ background: null,
471
+ scenarios: []
472
+ });
473
+ index = toIndex;
474
+ }
475
+ continue;
476
+ }
477
+ const headings = headingsInRange(lines, mask, 3, section.start + 1, section.end).filter((heading) => /^Requirement:\s*/i.test(heading.title));
478
+ for (let index = 0; index < headings.length; index += 1) {
479
+ const heading = headings[index];
480
+ const rule = parseOpenSpecRequirement(lines, mask, heading, index + 1 < headings.length ? tagBlockStart(lines, headings[index + 1].index) : section.end);
481
+ rule.operation = operation;
482
+ model.rules.push(rule);
483
+ }
484
+ }
485
+ }
486
+ function parseOpenSpecRequirement(lines, mask, heading, end) {
487
+ const childHeadings = headingsInRange(lines, mask, 4, heading.index + 1, end).filter((item) => /^(Scenario(?: Outline)?|Background):/i.test(item.title));
488
+ const firstChild = childHeadings[0] ? tagBlockStart(lines, childHeadings[0].index) : end;
489
+ const headerBody = trimBlank(lines.slice(heading.index + 1, firstChild));
490
+ let background = null;
491
+ let descriptionLines = headerBody;
492
+ const marker = headerBody.findIndex((line) => /^\*\*Background:/i.test(line));
493
+ if (marker >= 0) {
494
+ descriptionLines = trimBlank(headerBody.slice(0, marker));
495
+ background = parseOpenSpecBackground(headerBody.slice(marker), "rule");
496
+ }
497
+ const rule = {
498
+ name: heading.title.replace(/^Requirement:\s*/i, "").trim(),
499
+ description: descriptionLines.join("\n").trim(),
500
+ tags: precedingTags(lines, heading.index),
501
+ operation: void 0,
502
+ rename: null,
503
+ background,
504
+ scenarios: []
505
+ };
506
+ for (let index = 0; index < childHeadings.length; index += 1) {
507
+ const child = childHeadings[index];
508
+ const childEnd = index + 1 < childHeadings.length ? tagBlockStart(lines, childHeadings[index + 1].index) : end;
509
+ if (/^Background:/i.test(child.title)) rule.background = parseOpenSpecScenario(lines, mask, child, childEnd, true);
510
+ else rule.scenarios.push(parseOpenSpecScenario(lines, mask, child, childEnd, false));
511
+ }
512
+ return rule;
513
+ }
514
+ function parseOpenSpecBackground(lines, scope) {
515
+ const marker = lines[0]?.match(/^\*\*Background:(.*?)\*\*\s*$/i);
516
+ if (!marker) throw new ConversionError(`Unsupported ${scope} Requirements preamble; expected **Background:**`);
517
+ const parsed = parseOpenSpecBody(lines.slice(1));
518
+ return {
519
+ name: marker[1].trim(),
520
+ description: parsed.description,
521
+ steps: parsed.steps
522
+ };
523
+ }
524
+ function parseOpenSpecScenario(lines, mask, heading, end, background) {
525
+ const examplesHeadings = headingsInRange(lines, mask, 5, heading.index + 1, end).filter((item) => /^Examples:/i.test(item.title));
526
+ const bodyEnd = examplesHeadings[0] ? tagBlockStart(lines, examplesHeadings[0].index) : end;
527
+ const body = parseOpenSpecBody(lines.slice(heading.index + 1, bodyEnd));
528
+ if (background) return {
529
+ name: heading.title.replace(/^Background:\s*/i, "").trim(),
530
+ description: body.description,
531
+ steps: body.steps
532
+ };
533
+ const scenario = {
534
+ kind: /^Scenario Outline:/i.test(heading.title) ? "outline" : "scenario",
535
+ name: heading.title.replace(/^Scenario(?: Outline)?:\s*/i, "").trim(),
536
+ description: body.description,
537
+ tags: precedingTags(lines, heading.index),
538
+ steps: body.steps,
539
+ examples: []
540
+ };
541
+ for (let index = 0; index < examplesHeadings.length; index += 1) {
542
+ const example = examplesHeadings[index];
543
+ const exampleEnd = index + 1 < examplesHeadings.length ? tagBlockStart(lines, examplesHeadings[index + 1].index) : end;
544
+ const exampleLines = trimBlank(lines.slice(example.index + 1, exampleEnd));
545
+ const tableIndex = exampleLines.findIndex((line) => /^\s{2,}\|/.test(line));
546
+ if (tableIndex < 0) throw new ConversionError(`Examples "${example.title}" has no table`);
547
+ scenario.examples.push({
548
+ name: example.title.replace(/^Examples:\s*/i, "").trim(),
549
+ description: trimBlank(exampleLines.slice(0, tableIndex)).join("\n"),
550
+ tags: precedingTags(lines, example.index),
551
+ rows: parseTable(exampleLines.slice(tableIndex))
552
+ });
553
+ }
554
+ return scenario;
555
+ }
556
+ function parseOpenSpecBody(rawLines) {
557
+ const lines = trimBlank(rawLines);
558
+ const firstStep = lines.findIndex((line) => /^\s*[-*+]\s+\*\*(GIVEN|WHEN|THEN|AND|BUT)\*\*/i.test(line));
559
+ const description = firstStep < 0 ? lines.join("\n").trim() : trimBlank(lines.slice(0, firstStep)).join("\n");
560
+ const steps = [];
561
+ if (firstStep < 0) return {
562
+ description,
563
+ steps
564
+ };
565
+ let index = firstStep;
566
+ while (index < lines.length) {
567
+ if (!lines[index].trim()) {
568
+ index += 1;
569
+ continue;
570
+ }
571
+ const match = lines[index].match(/^\s*[-*+]\s+\*\*(GIVEN|WHEN|THEN|AND|BUT)\*\*\s+(.+)$/i);
572
+ if (!match) throw new ConversionError(`Unsupported OpenSpec scenario line: ${lines[index]}`);
573
+ const step = {
574
+ keyword: match[1].toUpperCase(),
575
+ text: match[2],
576
+ table: null,
577
+ docString: null
578
+ };
579
+ index += 1;
580
+ while (index < lines.length && !lines[index].trim()) index += 1;
581
+ if (index < lines.length && /^\s*```/.test(lines[index])) {
582
+ const opener = /^(\s*)```(.*)$/.exec(lines[index]);
583
+ if (!opener) throw new ConversionError("Invalid DocString opener");
584
+ const indent = opener[1].length;
585
+ const content = [];
586
+ index += 1;
587
+ while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) {
588
+ content.push(lines[index].slice(Math.min(indent, lines[index].length)));
589
+ index += 1;
590
+ }
591
+ if (index >= lines.length) throw new ConversionError("Unclosed OpenSpec DocString");
592
+ index += 1;
593
+ step.docString = {
594
+ mediaType: opener[2].trim(),
595
+ content: content.join("\n")
596
+ };
597
+ } else if (index < lines.length && /^\s{2,}\|/.test(lines[index])) {
598
+ const tableLines = [];
599
+ while (index < lines.length && /^\s{2,}\|/.test(lines[index])) tableLines.push(lines[index++]);
600
+ step.table = parseTable(tableLines);
601
+ }
602
+ steps.push(step);
603
+ }
604
+ return {
605
+ description,
606
+ steps
607
+ };
608
+ }
609
+ //#endregion
610
+ //#region src/renderers/feature.ts
611
+ function serializeFeature(model) {
612
+ const output = [];
613
+ pushFeatureTags(output, model.kind === "delta" ? [...model.tags, DELTA_TAG] : model.tags, 0);
614
+ output.push(`Feature: ${model.name}`);
615
+ pushFeatureDescription(output, model.description, 2);
616
+ if (model.background) serializeFeatureBackground(output, model.background, 2);
617
+ for (const scenario of model.featureScenarios) serializeFeatureScenario(output, scenario, 2);
618
+ for (const rule of model.rules) serializeFeatureRule(output, rule);
619
+ return `${trimBlank(output).join("\n")}\n`;
620
+ }
621
+ function serializeFeatureRule(output, rule) {
622
+ const operationTag = rule.operation ? [...OPERATION_TAGS].find(([, operation]) => operation === rule.operation)?.[0] : null;
623
+ output.push("");
624
+ pushFeatureTags(output, [...rule.tags, ...operationTag ? [operationTag] : []], 2);
625
+ output.push(` Rule: ${rule.operation === "RENAMED" ? "Rename requirement" : rule.name}`);
626
+ if (rule.operation === "RENAMED") {
627
+ pushFeatureDescription(output, `FROM: ### Requirement: ${rule.rename?.from}\nTO: ### Requirement: ${rule.rename?.to}`, 4);
628
+ return;
629
+ }
630
+ pushFeatureDescription(output, rule.description, 4);
631
+ if (rule.background) serializeFeatureBackground(output, rule.background, 4);
632
+ for (const scenario of rule.scenarios) serializeFeatureScenario(output, scenario, 4);
633
+ }
634
+ function serializeFeatureBackground(output, background, indent) {
635
+ output.push("", `${" ".repeat(indent)}Background:${background.name ? ` ${background.name}` : ""}`);
636
+ pushFeatureDescription(output, background.description, indent + 2);
637
+ pushFeatureSteps(output, background.steps, indent + 2);
638
+ }
639
+ function serializeFeatureScenario(output, scenario, indent) {
640
+ output.push("");
641
+ pushFeatureTags(output, scenario.tags, indent);
642
+ output.push(`${" ".repeat(indent)}${scenario.kind === "outline" ? "Scenario Outline" : "Scenario"}: ${scenario.name}`);
643
+ pushFeatureDescription(output, scenario.description, indent + 2);
644
+ pushFeatureSteps(output, scenario.steps, indent + 2);
645
+ for (const examples of scenario.examples) {
646
+ output.push("");
647
+ pushFeatureTags(output, examples.tags, indent + 2);
648
+ output.push(`${" ".repeat(indent + 2)}Examples: ${examples.name}`);
649
+ pushFeatureDescription(output, examples.description, indent + 4);
650
+ pushFeatureTable(output, examples.rows, indent + 4);
651
+ }
652
+ }
653
+ function pushFeatureSteps(output, steps, indent) {
654
+ const prefix = " ".repeat(indent);
655
+ for (const step of steps) {
656
+ output.push(`${prefix}${titleCase(step.keyword)} ${step.text}`);
657
+ if (step.table) pushFeatureTable(output, step.table, indent + 2);
658
+ if (step.docString) {
659
+ const fence = step.docString.content.split("\n").some((line) => line.trim() === "\"\"\"") ? "```" : "\"\"\"";
660
+ output.push(`${" ".repeat(indent + 2)}${fence}${step.docString.mediaType || ""}`);
661
+ for (const line of step.docString.content.split("\n")) output.push(`${" ".repeat(indent + 2)}${line}`);
662
+ output.push(`${" ".repeat(indent + 2)}${fence}`);
663
+ }
664
+ }
665
+ }
666
+ function pushFeatureTable(output, rows, indent) {
667
+ if (!rows?.length) return;
668
+ const prefix = " ".repeat(indent);
669
+ for (const row of rows) output.push(`${prefix}| ${row.map(escapeCell).join(" | ")} |`);
670
+ }
671
+ function pushFeatureDescription(output, description, indent) {
672
+ if (!description) return;
673
+ const prefix = " ".repeat(indent);
674
+ output.push("");
675
+ for (const line of description.split("\n")) output.push(line ? `${prefix}${line}` : "");
676
+ }
677
+ function pushFeatureTags(output, tags, indent) {
678
+ if (tags?.length) output.push(`${" ".repeat(indent)}${tags.join(" ")}`);
679
+ }
680
+ //#endregion
681
+ //#region src/renderers/mdg.ts
682
+ function serializeMdg(model) {
683
+ const output = [];
684
+ pushTags(output, model.kind === "delta" ? [...model.tags, DELTA_TAG] : model.tags);
685
+ output.push(`# Feature: ${model.name}`, "");
686
+ if (model.description) output.push(model.description, "");
687
+ if (model.background) serializeMdgBackground(output, model.background, 2);
688
+ for (const scenario of model.featureScenarios) serializeMdgScenario(output, scenario, 3);
689
+ for (const rule of model.rules) serializeMdgRule(output, rule);
690
+ return `${trimBlank(output).join("\n")}\n`;
691
+ }
692
+ function serializeMdgRule(output, rule) {
693
+ const operationTag = rule.operation ? [...OPERATION_TAGS].find(([, operation]) => operation === rule.operation)?.[0] : null;
694
+ pushTags(output, [...rule.tags, ...operationTag ? [operationTag] : []]);
695
+ output.push(`## Rule: ${rule.operation === "RENAMED" ? "Rename requirement" : rule.name}`, "");
696
+ if (rule.operation === "RENAMED") {
697
+ output.push(`FROM: \`### Requirement: ${rule.rename?.from}\``, `TO: \`### Requirement: ${rule.rename?.to}\``, "");
698
+ return;
699
+ }
700
+ if (rule.description) output.push(rule.description, "");
701
+ if (rule.background) serializeMdgBackground(output, rule.background, 3);
702
+ for (const scenario of rule.scenarios) serializeMdgScenario(output, scenario, 3);
703
+ }
704
+ function serializeMdgBackground(output, background, level) {
705
+ output.push(`${"#".repeat(level)} Background:${background.name ? ` ${background.name}` : ""}`, "");
706
+ pushDescriptionAndSteps(output, background, true);
707
+ }
708
+ function serializeMdgScenario(output, scenario, level) {
709
+ pushTags(output, scenario.tags);
710
+ output.push(`${"#".repeat(level)} ${scenario.kind === "outline" ? "Scenario Outline" : "Scenario"}: ${scenario.name}`, "");
711
+ pushDescriptionAndSteps(output, scenario, true);
712
+ for (const examples of scenario.examples) {
713
+ pushTags(output, examples.tags);
714
+ output.push(`${"#".repeat(level + 1)} Examples: ${examples.name}`, "");
715
+ if (examples.description) output.push(examples.description, "");
716
+ pushTable(output, examples.rows, true, false);
717
+ }
718
+ }
719
+ //#endregion
720
+ //#region src/renderers/openspec.ts
721
+ function serializeOpenSpec(model) {
722
+ const output = [];
723
+ pushTags(output, model.tags);
724
+ if (model.kind === "delta") pushTags(output, [DELTA_TAG]);
725
+ output.push(`# ${model.name}`, "");
726
+ if (model.description) output.push("## Purpose", "", model.description, "");
727
+ if (model.kind === "delta") serializeOpenSpecDelta(output, model);
728
+ else serializeOpenSpecMain(output, model);
729
+ return `${trimBlank(output).join("\n")}\n`;
730
+ }
731
+ function serializeOpenSpecMain(output, model) {
732
+ output.push("## Requirements", "");
733
+ if (model.background) {
734
+ output.push(`**Background:${model.background.name ? ` ${model.background.name}` : ""}**`, "");
735
+ pushDescriptionAndSteps(output, model.background);
736
+ }
737
+ if (model.featureScenarios.length) {
738
+ pushTags(output, [SYNTHETIC_TAG]);
739
+ output.push("### Requirement: Feature-level scenarios", "", "The system SHALL exhibit the feature-level behavior defined by the following scenarios.", "");
740
+ for (const scenario of model.featureScenarios) serializeOpenSpecScenario(output, scenario);
741
+ }
742
+ for (const rule of model.rules) serializeOpenSpecRule(output, rule);
743
+ }
744
+ function serializeOpenSpecDelta(output, model) {
745
+ for (const operation of new Set(model.rules.map((rule) => rule.operation))) {
746
+ const rules = model.rules.filter((rule) => rule.operation === operation);
747
+ if (!rules.length) continue;
748
+ output.push(`## ${operation} Requirements`, "");
749
+ if (operation === "RENAMED") for (const rule of rules) {
750
+ pushTags(output, rule.tags);
751
+ output.push(`- FROM: \`### Requirement: ${rule.rename?.from}\``, `- TO: \`### Requirement: ${rule.rename?.to}\``, "");
752
+ }
753
+ else if (operation === "REMOVED") for (const rule of rules) {
754
+ pushTags(output, rule.tags);
755
+ output.push(`### Requirement: ${rule.name}`, "");
756
+ }
757
+ else for (const rule of rules) serializeOpenSpecRule(output, rule);
758
+ }
759
+ }
760
+ function serializeOpenSpecRule(output, rule) {
761
+ pushTags(output, rule.tags);
762
+ output.push(`### Requirement: ${rule.name}`, "", rule.description, "");
763
+ if (rule.background) {
764
+ output.push(`#### Background: ${rule.background.name}`.trimEnd(), "");
765
+ pushDescriptionAndSteps(output, rule.background);
766
+ }
767
+ for (const scenario of rule.scenarios) serializeOpenSpecScenario(output, scenario);
768
+ }
769
+ function serializeOpenSpecScenario(output, scenario) {
770
+ pushTags(output, scenario.tags);
771
+ output.push(`#### ${scenario.kind === "outline" ? "Scenario Outline" : "Scenario"}: ${scenario.name}`, "");
772
+ pushDescriptionAndSteps(output, scenario);
773
+ for (const examples of scenario.examples) {
774
+ pushTags(output, examples.tags);
775
+ output.push(`##### Examples: ${examples.name}`, "");
776
+ if (examples.description) output.push(examples.description, "");
777
+ pushTable(output, examples.rows);
778
+ }
779
+ }
780
+ //#endregion
781
+ //#region src/validation/profile.ts
782
+ function validateProfile(model, sourceKind) {
783
+ if (!model.name) throw new ConversionError("Feature/spec name is required");
784
+ if (!model.description && model.kind === "main") throw new ConversionError("Feature Purpose/description is required");
785
+ const allTags = [
786
+ ...model.tags,
787
+ ...model.rules.flatMap((rule) => [...rule.tags, ...rule.scenarios.flatMap((scenario) => [...scenario.tags, ...scenario.examples.flatMap((examples) => examples.tags)])]),
788
+ ...model.featureScenarios.flatMap((scenario) => scenario.tags)
789
+ ];
790
+ for (const tag of allTags) if (RESERVED_PREFIXES.some((prefix) => tag.startsWith(prefix))) throw new ConversionError(`Reserved tag is not allowed as user metadata: ${tag}`);
791
+ if (model.kind === "main") {
792
+ if (!model.rules.length && !model.featureScenarios.length) throw new ConversionError("Feature has no behavior to convert");
793
+ for (const rule of model.rules) {
794
+ if (!/\b(?:SHALL|MUST)\b/.test(rule.description)) throw new ConversionError(`Rule/Requirement "${rule.name}" must contain SHALL or MUST`);
795
+ if (!rule.scenarios.length && !rule.background) throw new ConversionError(`Rule/Requirement "${rule.name}" has no scenarios or Background`);
796
+ }
797
+ } else {
798
+ if (model.background || model.featureScenarios.length) throw new ConversionError("Delta Features cannot contain feature-level Background or scenarios");
799
+ for (const rule of model.rules) {
800
+ if ((rule.operation === "ADDED" || rule.operation === "MODIFIED") && !/\b(?:SHALL|MUST)\b/.test(rule.description)) throw new ConversionError(`${rule.operation} Rule "${rule.name}" must contain SHALL or MUST`);
801
+ if ((rule.operation === "ADDED" || rule.operation === "MODIFIED") && !rule.scenarios.length && !rule.background) throw new ConversionError(`${rule.operation} Rule "${rule.name}" requires behavior`);
802
+ if (rule.operation === "REMOVED" && (rule.description || rule.scenarios.length || rule.background)) throw new ConversionError(`REMOVED Rule "${rule.name}" must contain only its name`);
803
+ if (rule.operation === "RENAMED" && !rule.rename) throw new ConversionError("RENAMED Rule requires FROM and TO");
804
+ }
805
+ }
806
+ for (const scenario of [...model.featureScenarios, ...model.rules.flatMap((rule) => rule.scenarios)]) {
807
+ if (!scenario.steps.length) throw new ConversionError(`Scenario "${scenario.name}" has no steps`);
808
+ if (scenario.kind === "outline" && !scenario.examples.length) throw new ConversionError(`Scenario Outline "${scenario.name}" has no Examples`);
809
+ if (scenario.kind === "scenario" && scenario.examples.length) throw new ConversionError(`Scenario "${scenario.name}" cannot contain Examples`);
810
+ }
811
+ if (sourceKind === "openspec" && model.kind === "delta" && !model.rules.length) throw new ConversionError("Delta has no operations");
812
+ }
813
+ //#endregion
814
+ //#region src/convert.ts
815
+ async function mdgToOpsx(text, options = {}) {
816
+ const source = parseAndValidateMdg(text, options.sourcePath);
817
+ if (!source.valid || !source.document) throw new ConversionError("Invalid Markdown with Gherkin input", gherkinDiagnostics(source));
818
+ const model = parseMdgModel(text, source.document);
819
+ validateProfile(model, "mdg");
820
+ const output = serializeOpenSpec(model);
821
+ const name = options.name || slugify(model.name);
822
+ const report = await validateOpenSpec(output, {
823
+ kind: model.kind,
824
+ name,
825
+ projectRoot: options.projectRoot
826
+ });
827
+ if (!report.valid) throw new ConversionError("Generated OpenSpec failed official validation", openSpecDiagnostics(report));
828
+ assertPreserved(model, parseOpenSpecModel(output, {
829
+ kind: model.kind,
830
+ name
831
+ }));
832
+ return output;
833
+ }
834
+ async function openSpecModel(text, options) {
835
+ const kind = detectOpenSpecKind(text);
836
+ const name = resolveOpenSpecName(text, options);
837
+ const report = await validateOpenSpec(text, {
838
+ kind,
839
+ name,
840
+ projectRoot: options.projectRoot
841
+ });
842
+ if (!report.valid) throw new ConversionError("Invalid OpenSpec input", openSpecDiagnostics(report));
843
+ const model = parseOpenSpecModel(text, {
844
+ kind,
845
+ name
846
+ });
847
+ validateProfile(model, "openspec");
848
+ return model;
849
+ }
850
+ async function opsxToMdg(text, options = {}) {
851
+ const model = await openSpecModel(text, options);
852
+ const output = serializeMdg(model);
853
+ const target = parseAndValidateMdg(output);
854
+ if (!target.valid || !target.document) throw new ConversionError("Generated Markdown with Gherkin failed official validation", gherkinDiagnostics(target));
855
+ assertPreserved(model, parseMdgModel(output, target.document));
856
+ return output;
857
+ }
858
+ async function opsxToFeature(text, options = {}) {
859
+ const model = await openSpecModel(text, options);
860
+ const output = serializeFeature(model);
861
+ const target = parseAndValidateFeature(output);
862
+ if (!target.valid || !target.document) throw new ConversionError("Generated Gherkin feature failed official validation", gherkinDiagnostics(target));
863
+ assertPreserved(model, parseFeatureModel(target.document));
864
+ return output;
865
+ }
866
+ //#endregion
867
+ //#region src/cli/converter.ts
868
+ async function runConverterCli(direction, argv = process.argv.slice(2)) {
869
+ try {
870
+ const options = parseArgs(argv, direction);
871
+ if (options.help) {
872
+ process.stdout.write(usage(direction));
873
+ return 0;
874
+ }
875
+ const input = await readInput(options);
876
+ const convert = {
877
+ "mdg-to-opsx": mdgToOpsx,
878
+ "opsx-to-mdg": opsxToMdg,
879
+ "opsx-to-feature": opsxToFeature
880
+ }[direction];
881
+ const output = await convert(input.text, {
882
+ sourcePath: input.path,
883
+ name: options.name,
884
+ projectRoot: options.projectRoot ? resolve(options.projectRoot) : void 0
885
+ });
886
+ if (options.output) await writeAtomic(resolve(options.output), output, options.force);
887
+ else process.stdout.write(output);
888
+ return 0;
889
+ } catch (error) {
890
+ if (error instanceof ConversionError) {
891
+ process.stderr.write(`${error.message}\n`);
892
+ for (const diagnostic of error.diagnostics) process.stderr.write(`- ${diagnostic}\n`);
893
+ return 1;
894
+ }
895
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
896
+ return 2;
897
+ }
898
+ }
899
+ function parseArgs(argv, direction) {
900
+ const options = {
901
+ force: false,
902
+ help: false
903
+ };
904
+ for (let index = 0; index < argv.length; index += 1) {
905
+ const argument = argv[index];
906
+ if (argument === "--help" || argument === "-h") options.help = true;
907
+ else if (argument === "--force") options.force = true;
908
+ else if (argument === "--text") options.text = requiredValue(argv, ++index, argument);
909
+ else if (argument === "--output" || argument === "-o") options.output = requiredValue(argv, ++index, argument);
910
+ else if (argument === "--name") options.name = requiredValue(argv, ++index, argument);
911
+ else if (argument === "--project-root") options.projectRoot = requiredValue(argv, ++index, argument);
912
+ else if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}\n${usage(direction)}`);
913
+ else if (!options.input) options.input = argument;
914
+ else throw new Error(`Only one input path is allowed\n${usage(direction)}`);
915
+ }
916
+ if (options.input && options.text !== void 0) throw new Error(`Use either an input path or --text, not both\n${usage(direction)}`);
917
+ if (options.force && !options.output) throw new Error("--force requires --output");
918
+ return options;
919
+ }
920
+ async function readInput(options) {
921
+ if (options.text !== void 0) return {
922
+ text: options.text,
923
+ path: void 0
924
+ };
925
+ if (options.input) {
926
+ const path = resolve(options.input);
927
+ return {
928
+ text: await readFile(path, "utf8"),
929
+ path
930
+ };
931
+ }
932
+ if (process.stdin.isTTY) throw new Error("No input: pass a path, --text, or pipe text on stdin");
933
+ return {
934
+ text: await readStream(process.stdin),
935
+ path: void 0
936
+ };
937
+ }
938
+ async function readStream(stream) {
939
+ stream.setEncoding("utf8");
940
+ let result = "";
941
+ for await (const chunk of stream) result += chunk;
942
+ return result;
943
+ }
944
+ async function writeAtomic(path, content, force) {
945
+ await mkdir(dirname(path), { recursive: true });
946
+ const temporary = resolve(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
947
+ await writeFile(temporary, content, {
948
+ encoding: "utf8",
949
+ flag: "wx"
950
+ });
951
+ try {
952
+ if (force) await rename(temporary, path);
953
+ else {
954
+ try {
955
+ await link(temporary, path);
956
+ } catch (error) {
957
+ if (isFsError(error, "EEXIST")) throw new Error(`Output already exists: ${path} (use --force to replace it)`);
958
+ throw error;
959
+ }
960
+ await unlink(temporary);
961
+ }
962
+ } catch (error) {
963
+ await unlink(temporary).catch(() => {});
964
+ throw error;
965
+ }
966
+ }
967
+ function requiredValue(argv, index, option) {
968
+ if (index >= argv.length) throw new Error(`Missing value for ${option}`);
969
+ return argv[index];
970
+ }
971
+ function usage(direction) {
972
+ return `Usage: ${direction} [input] [--text content] [-o output] [--force] [--name name] [--project-root path]\n`;
973
+ }
974
+ //#endregion
975
+ export { runConverterCli as t };
976
+
977
+ //# sourceMappingURL=converter-CFtzcfoM.mjs.map