@eddacraft/anvil-adapters 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 (183) hide show
  1. package/AGENTS.md +180 -0
  2. package/BMAD_ADAPTER_SPEC.md +489 -0
  3. package/LICENSE +14 -0
  4. package/README.md +500 -0
  5. package/dist/aps-markdown/adapter.d.ts +102 -0
  6. package/dist/aps-markdown/adapter.d.ts.map +1 -0
  7. package/dist/aps-markdown/adapter.js +351 -0
  8. package/dist/aps-markdown/index.d.ts +8 -0
  9. package/dist/aps-markdown/index.d.ts.map +1 -0
  10. package/dist/aps-markdown/index.js +7 -0
  11. package/dist/base/file-discovery.d.ts +63 -0
  12. package/dist/base/file-discovery.d.ts.map +1 -0
  13. package/dist/base/file-discovery.js +246 -0
  14. package/dist/base/index.d.ts +10 -0
  15. package/dist/base/index.d.ts.map +1 -0
  16. package/dist/base/index.js +9 -0
  17. package/dist/base/registry.d.ts +155 -0
  18. package/dist/base/registry.d.ts.map +1 -0
  19. package/dist/base/registry.js +227 -0
  20. package/dist/base/testing.d.ts +102 -0
  21. package/dist/base/testing.d.ts.map +1 -0
  22. package/dist/base/testing.js +221 -0
  23. package/dist/base/types.d.ts +255 -0
  24. package/dist/base/types.d.ts.map +1 -0
  25. package/dist/base/types.js +78 -0
  26. package/dist/base/utils.d.ts +127 -0
  27. package/dist/base/utils.d.ts.map +1 -0
  28. package/dist/base/utils.js +254 -0
  29. package/dist/bmad/format-adapter.d.ts +76 -0
  30. package/dist/bmad/format-adapter.d.ts.map +1 -0
  31. package/dist/bmad/format-adapter.js +186 -0
  32. package/dist/bmad/index.d.ts +12 -0
  33. package/dist/bmad/index.d.ts.map +1 -0
  34. package/dist/bmad/index.js +10 -0
  35. package/dist/bmad/parser.d.ts +12 -0
  36. package/dist/bmad/parser.d.ts.map +1 -0
  37. package/dist/bmad/parser.js +181 -0
  38. package/dist/bmad/serializer.d.ts +16 -0
  39. package/dist/bmad/serializer.d.ts.map +1 -0
  40. package/dist/bmad/serializer.js +170 -0
  41. package/dist/bmad/types.d.ts +127 -0
  42. package/dist/bmad/types.d.ts.map +1 -0
  43. package/dist/bmad/types.js +47 -0
  44. package/dist/bmad/utils.d.ts +120 -0
  45. package/dist/bmad/utils.d.ts.map +1 -0
  46. package/dist/bmad/utils.js +480 -0
  47. package/dist/common/index.d.ts +3 -0
  48. package/dist/common/index.d.ts.map +1 -0
  49. package/dist/common/index.js +2 -0
  50. package/dist/common/registry.d.ts +18 -0
  51. package/dist/common/registry.d.ts.map +1 -0
  52. package/dist/common/registry.js +58 -0
  53. package/dist/common/types.d.ts +68 -0
  54. package/dist/common/types.d.ts.map +1 -0
  55. package/dist/common/types.js +12 -0
  56. package/dist/generic/format-adapter.d.ts +64 -0
  57. package/dist/generic/format-adapter.d.ts.map +1 -0
  58. package/dist/generic/format-adapter.js +159 -0
  59. package/dist/generic/index.d.ts +10 -0
  60. package/dist/generic/index.d.ts.map +1 -0
  61. package/dist/generic/index.js +9 -0
  62. package/dist/generic/parser.d.ts +11 -0
  63. package/dist/generic/parser.d.ts.map +1 -0
  64. package/dist/generic/parser.js +106 -0
  65. package/dist/generic/serializer.d.ts +11 -0
  66. package/dist/generic/serializer.d.ts.map +1 -0
  67. package/dist/generic/serializer.js +118 -0
  68. package/dist/generic/types.d.ts +52 -0
  69. package/dist/generic/types.d.ts.map +1 -0
  70. package/dist/generic/types.js +6 -0
  71. package/dist/generic/utils.d.ts +51 -0
  72. package/dist/generic/utils.d.ts.map +1 -0
  73. package/dist/generic/utils.js +232 -0
  74. package/dist/index.d.ts +15 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +31 -0
  77. package/dist/speckit/export.d.ts +22 -0
  78. package/dist/speckit/export.d.ts.map +1 -0
  79. package/dist/speckit/export.js +384 -0
  80. package/dist/speckit/format-adapter.d.ts +104 -0
  81. package/dist/speckit/format-adapter.d.ts.map +1 -0
  82. package/dist/speckit/format-adapter.js +488 -0
  83. package/dist/speckit/import-v2.d.ts +33 -0
  84. package/dist/speckit/import-v2.d.ts.map +1 -0
  85. package/dist/speckit/import-v2.js +361 -0
  86. package/dist/speckit/import.d.ts +16 -0
  87. package/dist/speckit/import.d.ts.map +1 -0
  88. package/dist/speckit/import.js +247 -0
  89. package/dist/speckit/index.d.ts +5 -0
  90. package/dist/speckit/index.d.ts.map +1 -0
  91. package/dist/speckit/index.js +4 -0
  92. package/dist/speckit/parser.d.ts +28 -0
  93. package/dist/speckit/parser.d.ts.map +1 -0
  94. package/dist/speckit/parser.js +283 -0
  95. package/dist/speckit/parsers/plan-parser.d.ts +71 -0
  96. package/dist/speckit/parsers/plan-parser.d.ts.map +1 -0
  97. package/dist/speckit/parsers/plan-parser.js +216 -0
  98. package/dist/speckit/parsers/spec-parser.d.ts +67 -0
  99. package/dist/speckit/parsers/spec-parser.d.ts.map +1 -0
  100. package/dist/speckit/parsers/spec-parser.js +255 -0
  101. package/dist/speckit/parsers/tasks-parser.d.ts +57 -0
  102. package/dist/speckit/parsers/tasks-parser.d.ts.map +1 -0
  103. package/dist/speckit/parsers/tasks-parser.js +157 -0
  104. package/package.json +23 -0
  105. package/project.json +29 -0
  106. package/src/__tests__/adapter-edge-cases.test.ts +937 -0
  107. package/src/__tests__/bmad-format-adapter.test.ts +1470 -0
  108. package/src/__tests__/fixtures/aps/expected-output.json +83 -0
  109. package/src/__tests__/fixtures/bmad/invalid-malformed-yaml.md +16 -0
  110. package/src/__tests__/fixtures/bmad/invalid-no-requirements.md +23 -0
  111. package/src/__tests__/fixtures/bmad/invalid-only-yaml.md +16 -0
  112. package/src/__tests__/fixtures/bmad/invalid-too-short.md +3 -0
  113. package/src/__tests__/fixtures/bmad/invalid-wrong-format.md +40 -0
  114. package/src/__tests__/fixtures/bmad/valid-agent.md +27 -0
  115. package/src/__tests__/fixtures/bmad/valid-architecture.md +116 -0
  116. package/src/__tests__/fixtures/bmad/valid-complex-prd.md +161 -0
  117. package/src/__tests__/fixtures/bmad/valid-epic.md +73 -0
  118. package/src/__tests__/fixtures/bmad/valid-minimal-prd.md +19 -0
  119. package/src/__tests__/fixtures/bmad/valid-prd.md +107 -0
  120. package/src/__tests__/fixtures/bmad/valid-story.md +107 -0
  121. package/src/__tests__/fixtures/bmad/valid-task.md +79 -0
  122. package/src/__tests__/fixtures/bmad/valid-v6-prd.md +35 -0
  123. package/src/__tests__/fixtures/generic/plan-detailed.md +39 -0
  124. package/src/__tests__/fixtures/generic/prd-simple.md +27 -0
  125. package/src/__tests__/fixtures/generic/rfc-example.md +26 -0
  126. package/src/__tests__/fixtures/generic/todo-list.md +23 -0
  127. package/src/__tests__/fixtures/speckit/sample-plan.md +63 -0
  128. package/src/__tests__/fixtures/speckit/sample-spec-namespaced.md +50 -0
  129. package/src/__tests__/fixtures/speckit/sample-spec.md +105 -0
  130. package/src/__tests__/fixtures/speckit/sample-tasks.md +87 -0
  131. package/src/__tests__/fixtures/speckit-official/auth-feature/plan.md +272 -0
  132. package/src/__tests__/fixtures/speckit-official/auth-feature/spec.md +149 -0
  133. package/src/__tests__/fixtures/speckit-official/auth-feature/tasks.md +169 -0
  134. package/src/__tests__/generic-format-adapter.test.ts +398 -0
  135. package/src/__tests__/speckit-export.test.ts +233 -0
  136. package/src/__tests__/speckit-format-adapter.test.ts +832 -0
  137. package/src/__tests__/speckit-import-v2.test.ts +253 -0
  138. package/src/__tests__/speckit-import.test.ts +209 -0
  139. package/src/__tests__/speckit-parser.test.ts +219 -0
  140. package/src/__tests__/speckit-spec-parser.test.ts +120 -0
  141. package/src/aps-markdown/__tests__/__fixtures__/simple-leaf.aps.md +17 -0
  142. package/src/aps-markdown/__tests__/adapter.test.ts +393 -0
  143. package/src/aps-markdown/adapter.ts +455 -0
  144. package/src/aps-markdown/index.ts +8 -0
  145. package/src/base/__tests__/registry.test.ts +515 -0
  146. package/src/base/file-discovery.ts +305 -0
  147. package/src/base/index.ts +10 -0
  148. package/src/base/registry.ts +263 -0
  149. package/src/base/testing.ts +334 -0
  150. package/src/base/types.ts +342 -0
  151. package/src/base/utils.ts +306 -0
  152. package/src/bmad/format-adapter.ts +227 -0
  153. package/src/bmad/index.ts +21 -0
  154. package/src/bmad/parser.ts +224 -0
  155. package/src/bmad/serializer.ts +206 -0
  156. package/src/bmad/types.ts +135 -0
  157. package/src/bmad/utils.ts +575 -0
  158. package/src/common/index.ts +2 -0
  159. package/src/common/registry.ts +72 -0
  160. package/src/common/types.ts +84 -0
  161. package/src/generic/__tests__/serializer.test.ts +167 -0
  162. package/src/generic/format-adapter.ts +200 -0
  163. package/src/generic/index.ts +11 -0
  164. package/src/generic/parser.ts +129 -0
  165. package/src/generic/serializer.ts +134 -0
  166. package/src/generic/types.ts +53 -0
  167. package/src/generic/utils.ts +270 -0
  168. package/src/index.ts +48 -0
  169. package/src/speckit/export.ts +489 -0
  170. package/src/speckit/format-adapter.ts +595 -0
  171. package/src/speckit/import-v2.ts +445 -0
  172. package/src/speckit/import.ts +305 -0
  173. package/src/speckit/index.ts +4 -0
  174. package/src/speckit/parser.ts +351 -0
  175. package/src/speckit/parsers/plan-parser.ts +342 -0
  176. package/src/speckit/parsers/spec-parser.ts +379 -0
  177. package/src/speckit/parsers/tasks-parser.ts +246 -0
  178. package/tsconfig.json +26 -0
  179. package/tsconfig.lib.json +21 -0
  180. package/tsconfig.lib.tsbuildinfo +1 -0
  181. package/tsconfig.spec.json +9 -0
  182. package/tsconfig.tsbuildinfo +1 -0
  183. package/vitest.config.ts +14 -0
@@ -0,0 +1,305 @@
1
+ import { createPlan, type APSPlan, type Change, type Provenance } from '@eddacraft/anvil-core';
2
+ import type {
3
+ AdapterConfig,
4
+ ConversionError,
5
+ ConversionResult,
6
+ ConversionWarning,
7
+ ExternalSpec,
8
+ SpecContext,
9
+ } from '../common/types.js';
10
+ import { BaseAdapter } from '../common/types.js';
11
+ import { SpecKitParser } from './parser.js';
12
+
13
+ interface SpecKitSpec {
14
+ specContent?: string;
15
+ planContent?: string;
16
+ tasksContent?: string;
17
+ metadata?: Record<string, unknown>;
18
+ }
19
+
20
+ export class SpecKitImportAdapter extends BaseAdapter {
21
+ readonly name = 'speckit-import';
22
+ readonly version = '1.0.0';
23
+ readonly supportedFormats = ['speckit', 'spec.md'] as const;
24
+
25
+ private parser: SpecKitParser;
26
+
27
+ constructor(config: AdapterConfig = {}) {
28
+ super(config);
29
+ this.parser = new SpecKitParser();
30
+ }
31
+
32
+ async generateSpec(intent: string, context: SpecContext): Promise<APSPlan> {
33
+ const provenance: Provenance = {
34
+ timestamp: new Date().toISOString(),
35
+ source: 'cli',
36
+ version: this.version,
37
+ author: context.author,
38
+ repository: context.repositoryPath,
39
+ branch: context.branch,
40
+ commit: context.commit,
41
+ };
42
+
43
+ const changes: Change[] = [
44
+ {
45
+ type: 'file_create',
46
+ path: 'spec.md',
47
+ description: 'Create initial specification file',
48
+ content: `# Specification\n\n## Intent\n\n${intent}\n\n## Overview\n\n[Describe the overall approach]\n\n## Requirements\n\n- [List prerequisites]\n\n## Changes\n\n- [List proposed changes]\n`,
49
+ },
50
+ ];
51
+
52
+ const planId = `aps-${Date.now().toString(16).substring(0, 8)}`;
53
+
54
+ const plan = {
55
+ ...createPlan({
56
+ id: planId,
57
+ intent,
58
+ provenance,
59
+ changes,
60
+ }),
61
+ schema_version: '0.1.0' as const,
62
+ hash: '0'.repeat(64), // Placeholder hash
63
+ } as APSPlan;
64
+ return plan;
65
+ }
66
+
67
+ async validateSpec(spec: APSPlan): Promise<import('@eddacraft/anvil-core').ValidationResult> {
68
+ const errors: Array<{ field: string; message: string }> = [];
69
+ const warnings: Array<{ field: string; message: string }> = [];
70
+
71
+ if (spec.proposed_changes.length === 0) {
72
+ warnings.push({
73
+ field: 'proposed_changes',
74
+ message: 'No changes specified in the plan',
75
+ });
76
+ }
77
+
78
+ for (let i = 0; i < spec.proposed_changes.length; i++) {
79
+ const change = spec.proposed_changes[i];
80
+ if (!change.description || change.description.length < 10) {
81
+ warnings.push({
82
+ field: `proposed_changes[${i}].description`,
83
+ message: 'Change description is too short or missing',
84
+ });
85
+ }
86
+
87
+ if (!change.path && change.type !== 'script_execute') {
88
+ errors.push({
89
+ field: `proposed_changes[${i}].path`,
90
+ message: `Path is required for change type '${change.type}'`,
91
+ });
92
+ }
93
+ }
94
+
95
+ const issues: Array<{
96
+ path: string;
97
+ message: string;
98
+ code: string;
99
+ severity: 'error' | 'warning';
100
+ }> = [
101
+ ...errors.map((e) => ({
102
+ path: e.field,
103
+ message: e.message,
104
+ code: 'VALIDATION_ERROR',
105
+ severity: 'error' as const,
106
+ })),
107
+ ...warnings.map((w) => ({
108
+ path: w.field,
109
+ message: w.message,
110
+ code: 'VALIDATION_WARNING',
111
+ severity: 'warning' as const,
112
+ })),
113
+ ];
114
+
115
+ return {
116
+ valid: errors.length === 0,
117
+ data: spec,
118
+ issues: issues.length > 0 ? issues : undefined,
119
+ summary: errors.length === 0 ? 'Validation passed' : `Found ${errors.length} error(s)`,
120
+ };
121
+ }
122
+
123
+ async convertToAPS(spec: ExternalSpec): Promise<ConversionResult<APSPlan>> {
124
+ const errors: ConversionError[] = [];
125
+ const warnings: ConversionWarning[] = [];
126
+
127
+ if (!this.canImport(spec.format)) {
128
+ return {
129
+ success: false,
130
+ errors: [
131
+ {
132
+ code: 'UNSUPPORTED_FORMAT',
133
+ message: `Format '${spec.format}' is not supported by this adapter`,
134
+ },
135
+ ],
136
+ };
137
+ }
138
+
139
+ try {
140
+ const specKitSpec = spec.content as SpecKitSpec;
141
+
142
+ if (!specKitSpec.specContent) {
143
+ errors.push({
144
+ code: 'MISSING_SPEC_CONTENT',
145
+ message: 'spec.md content is required',
146
+ });
147
+ return { success: false, errors };
148
+ }
149
+
150
+ const parsed = this.parser.parseSpecMarkdown(specKitSpec.specContent);
151
+
152
+ if (!parsed.intent && !parsed.overview) {
153
+ errors.push({
154
+ code: 'MISSING_INTENT',
155
+ message: 'No intent or overview section found in spec.md',
156
+ });
157
+ } else if (!parsed.intent) {
158
+ warnings.push({
159
+ code: 'MISSING_INTENT',
160
+ message: 'No intent section found, using overview as fallback',
161
+ });
162
+ }
163
+
164
+ const changes = this.convertChangesToAPS(parsed.changes || [], errors, warnings);
165
+
166
+ if (errors.length > 0) {
167
+ return { success: false, errors, warnings };
168
+ }
169
+
170
+ const intent = parsed.intent || parsed.overview || 'Specification from SpecKit';
171
+
172
+ const provenance: Provenance = {
173
+ timestamp: (spec.metadata?.['timestamp'] as string) || new Date().toISOString(),
174
+ source: 'cli',
175
+ version: this.version,
176
+ author: spec.metadata?.['author'] as string,
177
+ repository: spec.metadata?.['repository'] as string,
178
+ branch: spec.metadata?.['branch'] as string,
179
+ commit: spec.metadata?.['commit'] as string,
180
+ };
181
+
182
+ const planId = `aps-${Date.now().toString(16).substring(0, 8)}`;
183
+
184
+ try {
185
+ const plan = {
186
+ ...createPlan({
187
+ id: planId,
188
+ intent: intent.substring(0, 500),
189
+ provenance,
190
+ changes,
191
+ }),
192
+ schema_version: '0.1.0' as const,
193
+ hash: '0'.repeat(64), // Placeholder hash
194
+ metadata: {
195
+ ...parsed.metadata,
196
+ source_format: 'speckit',
197
+ goals: parsed.goals,
198
+ requirements: parsed.requirements,
199
+ overview: parsed.overview,
200
+ },
201
+ } as APSPlan;
202
+ return {
203
+ success: true,
204
+ data: plan,
205
+ warnings: warnings.length > 0 ? warnings : undefined,
206
+ };
207
+ } catch (error) {
208
+ errors.push({
209
+ code: 'APS_CREATION_FAILED',
210
+ message: error instanceof Error ? error.message : 'Failed to create APS plan',
211
+ });
212
+ return { success: false, errors };
213
+ }
214
+ } catch (error) {
215
+ errors.push({
216
+ code: 'CONVERSION_ERROR',
217
+ message: error instanceof Error ? error.message : 'Unknown conversion error',
218
+ });
219
+ return { success: false, errors };
220
+ }
221
+ }
222
+
223
+ async convertFromAPS(_spec: APSPlan): Promise<ConversionResult<ExternalSpec>> {
224
+ return {
225
+ success: false,
226
+ errors: [
227
+ {
228
+ code: 'NOT_IMPLEMENTED',
229
+ message: 'Export to SpecKit format is handled by speckit-export adapter',
230
+ },
231
+ ],
232
+ };
233
+ }
234
+
235
+ private convertChangesToAPS(
236
+ changes: Array<{ type: string; description: string; path?: string; content?: string }>,
237
+ errors: ConversionError[],
238
+ warnings: ConversionWarning[]
239
+ ): Change[] {
240
+ const apsChanges: Change[] = [];
241
+
242
+ for (let i = 0; i < changes.length; i++) {
243
+ const change = changes[i];
244
+
245
+ if (!change.description) {
246
+ warnings.push({
247
+ code: 'EMPTY_DESCRIPTION',
248
+ message: `Change ${i + 1} has no description`,
249
+ path: `changes[${i}]`,
250
+ });
251
+ continue;
252
+ }
253
+
254
+ const validTypes = [
255
+ 'file_create',
256
+ 'file_update',
257
+ 'file_delete',
258
+ 'config_update',
259
+ 'dependency_add',
260
+ 'dependency_remove',
261
+ 'dependency_update',
262
+ 'script_execute',
263
+ ];
264
+
265
+ if (!validTypes.includes(change.type)) {
266
+ warnings.push({
267
+ code: 'UNKNOWN_CHANGE_TYPE',
268
+ message: `Unknown change type '${change.type}', defaulting to 'script_execute'`,
269
+ path: `changes[${i}].type`,
270
+ });
271
+ change.type = 'script_execute';
272
+ }
273
+
274
+ const apsChange: Change = {
275
+ type: change.type as Change['type'],
276
+ path: change.path || '',
277
+ description: change.description,
278
+ };
279
+
280
+ if (change.content) {
281
+ apsChange.content = change.content;
282
+ }
283
+
284
+ if (!apsChange.path && apsChange.type !== 'script_execute') {
285
+ warnings.push({
286
+ code: 'MISSING_PATH',
287
+ message: `Path not specified for ${apsChange.type}, using placeholder`,
288
+ path: `changes[${i}].path`,
289
+ });
290
+ apsChange.path = '<path-to-be-specified>';
291
+ }
292
+
293
+ apsChanges.push(apsChange);
294
+ }
295
+
296
+ if (apsChanges.length === 0) {
297
+ warnings.push({
298
+ code: 'NO_CHANGES',
299
+ message: 'No valid changes found in specification',
300
+ });
301
+ }
302
+
303
+ return apsChanges;
304
+ }
305
+ }
@@ -0,0 +1,4 @@
1
+ export { SpecKitImportAdapter } from './import.js';
2
+ export { SpecKitExportAdapter } from './export.js';
3
+ export { SpecKitParser } from './parser.js';
4
+ export { SpecKitFormatAdapter, createSpecKitAdapter } from './format-adapter.js';
@@ -0,0 +1,351 @@
1
+ import { validateRelativePath } from '@eddacraft/anvil-core';
2
+
3
+ interface MarkdownSection {
4
+ title: string;
5
+ level: number;
6
+ content: string;
7
+ subsections: MarkdownSection[];
8
+ }
9
+
10
+ interface ParsedSpecKit {
11
+ intent?: string;
12
+ overview?: string;
13
+ goals?: string[];
14
+ requirements?: string[];
15
+ changes?: Array<{
16
+ type: string;
17
+ description: string;
18
+ path?: string;
19
+ content?: string;
20
+ }>;
21
+ metadata?: Record<string, unknown>;
22
+ }
23
+
24
+ /**
25
+ * Validate and sanitize a file path to prevent path traversal attacks.
26
+ * Returns undefined if the path is invalid (absolute, contains null bytes, or escapes parent directory).
27
+ */
28
+ function safePath(raw: string | undefined): string | undefined {
29
+ if (!raw) return undefined;
30
+ try {
31
+ return validateRelativePath(raw);
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ export class SpecKitParser {
38
+ private static readonly SPEC_SECTIONS = {
39
+ intent: ['intent', 'purpose', 'objective'],
40
+ overview: ['overview', 'summary', 'description'],
41
+ goals: ['goals', 'objectives', 'outcomes'],
42
+ requirements: ['requirements', 'prerequisites', 'dependencies'],
43
+ changes: ['changes', 'modifications', 'alterations', 'tasks'],
44
+ } as const;
45
+
46
+ /** Maximum input size for SpecKit parsing (2MB) */
47
+ private static readonly MAX_INPUT_SIZE = 2 * 1024 * 1024;
48
+
49
+ parseSpecMarkdown(content: string): ParsedSpecKit {
50
+ if (content.length > SpecKitParser.MAX_INPUT_SIZE) {
51
+ throw new Error(`Input exceeds maximum size of ${SpecKitParser.MAX_INPUT_SIZE} bytes`);
52
+ }
53
+ const sections = this.parseMarkdownSections(content);
54
+ const result: ParsedSpecKit = {
55
+ metadata: {},
56
+ };
57
+
58
+ for (const section of sections) {
59
+ this.extractSectionData(section, result);
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ private parseMarkdownSections(content: string): MarkdownSection[] {
66
+ const lines = content.split('\n');
67
+ const sections: MarkdownSection[] = [];
68
+ const stack: MarkdownSection[] = [];
69
+ let currentContent: string[] = [];
70
+
71
+ for (const line of lines) {
72
+ const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
73
+
74
+ if (headerMatch) {
75
+ if (currentContent.length > 0 && stack.length > 0) {
76
+ stack[stack.length - 1].content = currentContent.join('\n').trim();
77
+ }
78
+
79
+ const level = headerMatch[1].length;
80
+ const title = headerMatch[2].trim();
81
+
82
+ const newSection: MarkdownSection = {
83
+ title,
84
+ level,
85
+ content: '',
86
+ subsections: [],
87
+ };
88
+
89
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
90
+ stack.pop();
91
+ }
92
+
93
+ if (stack.length === 0) {
94
+ sections.push(newSection);
95
+ } else {
96
+ stack[stack.length - 1].subsections.push(newSection);
97
+ }
98
+
99
+ stack.push(newSection);
100
+ currentContent = [];
101
+ } else {
102
+ currentContent.push(line);
103
+ }
104
+ }
105
+
106
+ if (currentContent.length > 0 && stack.length > 0) {
107
+ stack[stack.length - 1].content = currentContent.join('\n').trim();
108
+ }
109
+
110
+ return sections;
111
+ }
112
+
113
+ private extractSectionData(section: MarkdownSection, result: ParsedSpecKit): void {
114
+ const sectionTitleLower = section.title.toLowerCase();
115
+ let sectionWasProcessed = false;
116
+
117
+ for (const [key, aliases] of Object.entries(SpecKitParser.SPEC_SECTIONS)) {
118
+ // Use word boundary matching to avoid partial matches (e.g., "objective" shouldn't match "objectives")
119
+ if (
120
+ aliases.some((alias) => {
121
+ const regex = new RegExp(`\\b${alias}\\b`, 'i');
122
+ return regex.test(sectionTitleLower);
123
+ })
124
+ ) {
125
+ sectionWasProcessed = true;
126
+ switch (key) {
127
+ case 'intent':
128
+ // For intent, get only the paragraph text, not list items
129
+ result.intent = this.extractParagraphText(section.content);
130
+ break;
131
+ case 'overview':
132
+ // For overview, get only the paragraph text, not list items
133
+ result.overview = this.extractParagraphText(section.content);
134
+ break;
135
+ case 'goals':
136
+ result.goals = this.parseListItems(section.content);
137
+ break;
138
+ case 'requirements':
139
+ result.requirements = this.parseListItems(section.content);
140
+ break;
141
+ case 'changes':
142
+ // parseChanges handles all nested subsections, so don't recurse into them
143
+ result.changes = this.parseChanges(section);
144
+ return; // Exit early - changes section is fully processed
145
+ }
146
+ }
147
+ }
148
+
149
+ // Only recurse into subsections if this section wasn't fully processed by a specialized method
150
+ // or if the section didn't match any known section types
151
+ if (!sectionWasProcessed) {
152
+ for (const subsection of section.subsections) {
153
+ this.extractSectionData(subsection, result);
154
+ }
155
+ }
156
+ }
157
+
158
+ private extractParagraphText(content: string): string {
159
+ const lines = content.split('\n');
160
+ const paragraphLines: string[] = [];
161
+ let hasContent = false;
162
+
163
+ for (const line of lines) {
164
+ const trimmedLine = line.trim();
165
+
166
+ // Stop at list items
167
+ if (trimmedLine.match(/^[-*+]\s+/) || trimmedLine.match(/^\d+\.\s+/)) {
168
+ break;
169
+ }
170
+
171
+ // Add non-empty lines
172
+ if (trimmedLine) {
173
+ paragraphLines.push(trimmedLine);
174
+ hasContent = true;
175
+ } else if (hasContent && paragraphLines.length > 0) {
176
+ // Stop at empty line after we've collected some content
177
+ break;
178
+ }
179
+ }
180
+
181
+ return paragraphLines.join(' ').trim();
182
+ }
183
+
184
+ private parseListItems(content: string): string[] {
185
+ const lines = content.split('\n');
186
+ const items: string[] = [];
187
+ let currentItem = '';
188
+
189
+ for (const line of lines) {
190
+ const listMatch = line.match(/^[\s]*[-*+]\s+(.+)$/);
191
+ const numberedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/);
192
+
193
+ if (listMatch || numberedMatch) {
194
+ if (currentItem) {
195
+ items.push(currentItem.trim());
196
+ }
197
+ currentItem = (listMatch?.[1] || numberedMatch?.[1] || '').trim();
198
+ } else if (currentItem && line.trim()) {
199
+ currentItem += ' ' + line.trim();
200
+ }
201
+ }
202
+
203
+ if (currentItem) {
204
+ items.push(currentItem.trim());
205
+ }
206
+
207
+ return items;
208
+ }
209
+
210
+ private parseChanges(section: MarkdownSection): Array<{
211
+ type: string;
212
+ description: string;
213
+ path?: string;
214
+ content?: string;
215
+ }> {
216
+ const changes: Array<{
217
+ type: string;
218
+ description: string;
219
+ path?: string;
220
+ content?: string;
221
+ }> = [];
222
+
223
+ // Process direct subsections
224
+ for (const subsection of section.subsections) {
225
+ // Check if this subsection is a grouping section (like "Files to Create")
226
+ const subsectionTitleLower = subsection.title.toLowerCase();
227
+ const isGroupingSection =
228
+ subsectionTitleLower.includes('files to') ||
229
+ subsectionTitleLower.includes('configuration') ||
230
+ subsectionTitleLower.includes('dependencies') ||
231
+ subsectionTitleLower.includes('scripts');
232
+
233
+ if (isGroupingSection && subsection.subsections.length > 0) {
234
+ // Process nested subsections within grouping sections
235
+ for (const nestedSection of subsection.subsections) {
236
+ const change = this.parseChangeSection(nestedSection);
237
+ if (change) {
238
+ changes.push(change);
239
+ }
240
+ }
241
+ } else {
242
+ // Process as a direct change section
243
+ const change = this.parseChangeSection(subsection);
244
+ if (change) {
245
+ changes.push(change);
246
+ }
247
+ }
248
+ }
249
+
250
+ // Fallback to parsing list items if no subsections found
251
+ if (changes.length === 0) {
252
+ const listItems = this.parseListItems(section.content);
253
+ for (const item of listItems) {
254
+ const change = this.parseChangeFromListItem(item);
255
+ if (change) {
256
+ changes.push(change);
257
+ }
258
+ }
259
+ }
260
+
261
+ return changes;
262
+ }
263
+
264
+ private parseChangeSection(section: MarkdownSection): {
265
+ type: string;
266
+ description: string;
267
+ path?: string;
268
+ content?: string;
269
+ } | null {
270
+ const titleLower = section.title.toLowerCase();
271
+ let type = 'script_execute';
272
+
273
+ if (titleLower.includes('create') || titleLower.includes('new')) {
274
+ type = 'file_create';
275
+ } else if (titleLower.includes('update') || titleLower.includes('modify')) {
276
+ type = 'file_update';
277
+ } else if (titleLower.includes('delete') || titleLower.includes('remove')) {
278
+ type = 'file_delete';
279
+ } else if (titleLower.includes('config')) {
280
+ type = 'config_update';
281
+ } else if (titleLower.includes('dependency') || titleLower.includes('package')) {
282
+ if (titleLower.includes('add') || titleLower.includes('install')) {
283
+ type = 'dependency_add';
284
+ } else if (titleLower.includes('remove') || titleLower.includes('uninstall')) {
285
+ type = 'dependency_remove';
286
+ } else {
287
+ type = 'dependency_update';
288
+ }
289
+ }
290
+
291
+ // Look for path in title first, then in content
292
+ let pathMatch = section.title.match(/`([^`]+)`/);
293
+ if (!pathMatch && section.content) {
294
+ // Look for path in the first line of content
295
+ const firstLine = section.content.split('\n')[0];
296
+ pathMatch = firstLine.match(/`([^`]+)`/);
297
+ }
298
+ const path = safePath(pathMatch?.[1]);
299
+
300
+ const codeBlockMatch = section.content.match(/```[\w]*\n([\s\S]*?)```/);
301
+ const content = codeBlockMatch ? codeBlockMatch[1].trim() : undefined;
302
+
303
+ return {
304
+ type,
305
+ description: section.title,
306
+ path,
307
+ content,
308
+ };
309
+ }
310
+
311
+ private parseChangeFromListItem(item: string): {
312
+ type: string;
313
+ description: string;
314
+ path?: string;
315
+ } | null {
316
+ const itemLower = item.toLowerCase();
317
+ let type = 'script_execute';
318
+
319
+ // Check for script execution patterns first
320
+ if (
321
+ itemLower.includes('run') ||
322
+ itemLower.includes('execute') ||
323
+ itemLower.includes('script')
324
+ ) {
325
+ type = 'script_execute';
326
+ } else if (itemLower.includes('create') || itemLower.includes('new file')) {
327
+ type = 'file_create';
328
+ } else if (itemLower.includes('delete') || itemLower.includes('remove file')) {
329
+ type = 'file_delete';
330
+ } else if (itemLower.includes('update dependency')) {
331
+ type = 'dependency_update';
332
+ } else if (itemLower.includes('install') || itemLower.includes('add dependency')) {
333
+ type = 'dependency_add';
334
+ } else if (itemLower.includes('uninstall') || itemLower.includes('remove dependency')) {
335
+ type = 'dependency_remove';
336
+ } else if (itemLower.includes('update') || itemLower.includes('modify')) {
337
+ type = 'file_update';
338
+ } else if (itemLower.includes('config')) {
339
+ type = 'config_update';
340
+ }
341
+
342
+ const pathMatch = item.match(/`([^`]+)`/);
343
+ const path = safePath(pathMatch?.[1]);
344
+
345
+ return {
346
+ type,
347
+ description: item,
348
+ path,
349
+ };
350
+ }
351
+ }