@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.
- package/AGENTS.md +180 -0
- package/BMAD_ADAPTER_SPEC.md +489 -0
- package/LICENSE +14 -0
- package/README.md +500 -0
- package/dist/aps-markdown/adapter.d.ts +102 -0
- package/dist/aps-markdown/adapter.d.ts.map +1 -0
- package/dist/aps-markdown/adapter.js +351 -0
- package/dist/aps-markdown/index.d.ts +8 -0
- package/dist/aps-markdown/index.d.ts.map +1 -0
- package/dist/aps-markdown/index.js +7 -0
- package/dist/base/file-discovery.d.ts +63 -0
- package/dist/base/file-discovery.d.ts.map +1 -0
- package/dist/base/file-discovery.js +246 -0
- package/dist/base/index.d.ts +10 -0
- package/dist/base/index.d.ts.map +1 -0
- package/dist/base/index.js +9 -0
- package/dist/base/registry.d.ts +155 -0
- package/dist/base/registry.d.ts.map +1 -0
- package/dist/base/registry.js +227 -0
- package/dist/base/testing.d.ts +102 -0
- package/dist/base/testing.d.ts.map +1 -0
- package/dist/base/testing.js +221 -0
- package/dist/base/types.d.ts +255 -0
- package/dist/base/types.d.ts.map +1 -0
- package/dist/base/types.js +78 -0
- package/dist/base/utils.d.ts +127 -0
- package/dist/base/utils.d.ts.map +1 -0
- package/dist/base/utils.js +254 -0
- package/dist/bmad/format-adapter.d.ts +76 -0
- package/dist/bmad/format-adapter.d.ts.map +1 -0
- package/dist/bmad/format-adapter.js +186 -0
- package/dist/bmad/index.d.ts +12 -0
- package/dist/bmad/index.d.ts.map +1 -0
- package/dist/bmad/index.js +10 -0
- package/dist/bmad/parser.d.ts +12 -0
- package/dist/bmad/parser.d.ts.map +1 -0
- package/dist/bmad/parser.js +181 -0
- package/dist/bmad/serializer.d.ts +16 -0
- package/dist/bmad/serializer.d.ts.map +1 -0
- package/dist/bmad/serializer.js +170 -0
- package/dist/bmad/types.d.ts +127 -0
- package/dist/bmad/types.d.ts.map +1 -0
- package/dist/bmad/types.js +47 -0
- package/dist/bmad/utils.d.ts +120 -0
- package/dist/bmad/utils.d.ts.map +1 -0
- package/dist/bmad/utils.js +480 -0
- package/dist/common/index.d.ts +3 -0
- package/dist/common/index.d.ts.map +1 -0
- package/dist/common/index.js +2 -0
- package/dist/common/registry.d.ts +18 -0
- package/dist/common/registry.d.ts.map +1 -0
- package/dist/common/registry.js +58 -0
- package/dist/common/types.d.ts +68 -0
- package/dist/common/types.d.ts.map +1 -0
- package/dist/common/types.js +12 -0
- package/dist/generic/format-adapter.d.ts +64 -0
- package/dist/generic/format-adapter.d.ts.map +1 -0
- package/dist/generic/format-adapter.js +159 -0
- package/dist/generic/index.d.ts +10 -0
- package/dist/generic/index.d.ts.map +1 -0
- package/dist/generic/index.js +9 -0
- package/dist/generic/parser.d.ts +11 -0
- package/dist/generic/parser.d.ts.map +1 -0
- package/dist/generic/parser.js +106 -0
- package/dist/generic/serializer.d.ts +11 -0
- package/dist/generic/serializer.d.ts.map +1 -0
- package/dist/generic/serializer.js +118 -0
- package/dist/generic/types.d.ts +52 -0
- package/dist/generic/types.d.ts.map +1 -0
- package/dist/generic/types.js +6 -0
- package/dist/generic/utils.d.ts +51 -0
- package/dist/generic/utils.d.ts.map +1 -0
- package/dist/generic/utils.js +232 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/speckit/export.d.ts +22 -0
- package/dist/speckit/export.d.ts.map +1 -0
- package/dist/speckit/export.js +384 -0
- package/dist/speckit/format-adapter.d.ts +104 -0
- package/dist/speckit/format-adapter.d.ts.map +1 -0
- package/dist/speckit/format-adapter.js +488 -0
- package/dist/speckit/import-v2.d.ts +33 -0
- package/dist/speckit/import-v2.d.ts.map +1 -0
- package/dist/speckit/import-v2.js +361 -0
- package/dist/speckit/import.d.ts +16 -0
- package/dist/speckit/import.d.ts.map +1 -0
- package/dist/speckit/import.js +247 -0
- package/dist/speckit/index.d.ts +5 -0
- package/dist/speckit/index.d.ts.map +1 -0
- package/dist/speckit/index.js +4 -0
- package/dist/speckit/parser.d.ts +28 -0
- package/dist/speckit/parser.d.ts.map +1 -0
- package/dist/speckit/parser.js +283 -0
- package/dist/speckit/parsers/plan-parser.d.ts +71 -0
- package/dist/speckit/parsers/plan-parser.d.ts.map +1 -0
- package/dist/speckit/parsers/plan-parser.js +216 -0
- package/dist/speckit/parsers/spec-parser.d.ts +67 -0
- package/dist/speckit/parsers/spec-parser.d.ts.map +1 -0
- package/dist/speckit/parsers/spec-parser.js +255 -0
- package/dist/speckit/parsers/tasks-parser.d.ts +57 -0
- package/dist/speckit/parsers/tasks-parser.d.ts.map +1 -0
- package/dist/speckit/parsers/tasks-parser.js +157 -0
- package/package.json +23 -0
- package/project.json +29 -0
- package/src/__tests__/adapter-edge-cases.test.ts +937 -0
- package/src/__tests__/bmad-format-adapter.test.ts +1470 -0
- package/src/__tests__/fixtures/aps/expected-output.json +83 -0
- package/src/__tests__/fixtures/bmad/invalid-malformed-yaml.md +16 -0
- package/src/__tests__/fixtures/bmad/invalid-no-requirements.md +23 -0
- package/src/__tests__/fixtures/bmad/invalid-only-yaml.md +16 -0
- package/src/__tests__/fixtures/bmad/invalid-too-short.md +3 -0
- package/src/__tests__/fixtures/bmad/invalid-wrong-format.md +40 -0
- package/src/__tests__/fixtures/bmad/valid-agent.md +27 -0
- package/src/__tests__/fixtures/bmad/valid-architecture.md +116 -0
- package/src/__tests__/fixtures/bmad/valid-complex-prd.md +161 -0
- package/src/__tests__/fixtures/bmad/valid-epic.md +73 -0
- package/src/__tests__/fixtures/bmad/valid-minimal-prd.md +19 -0
- package/src/__tests__/fixtures/bmad/valid-prd.md +107 -0
- package/src/__tests__/fixtures/bmad/valid-story.md +107 -0
- package/src/__tests__/fixtures/bmad/valid-task.md +79 -0
- package/src/__tests__/fixtures/bmad/valid-v6-prd.md +35 -0
- package/src/__tests__/fixtures/generic/plan-detailed.md +39 -0
- package/src/__tests__/fixtures/generic/prd-simple.md +27 -0
- package/src/__tests__/fixtures/generic/rfc-example.md +26 -0
- package/src/__tests__/fixtures/generic/todo-list.md +23 -0
- package/src/__tests__/fixtures/speckit/sample-plan.md +63 -0
- package/src/__tests__/fixtures/speckit/sample-spec-namespaced.md +50 -0
- package/src/__tests__/fixtures/speckit/sample-spec.md +105 -0
- package/src/__tests__/fixtures/speckit/sample-tasks.md +87 -0
- package/src/__tests__/fixtures/speckit-official/auth-feature/plan.md +272 -0
- package/src/__tests__/fixtures/speckit-official/auth-feature/spec.md +149 -0
- package/src/__tests__/fixtures/speckit-official/auth-feature/tasks.md +169 -0
- package/src/__tests__/generic-format-adapter.test.ts +398 -0
- package/src/__tests__/speckit-export.test.ts +233 -0
- package/src/__tests__/speckit-format-adapter.test.ts +832 -0
- package/src/__tests__/speckit-import-v2.test.ts +253 -0
- package/src/__tests__/speckit-import.test.ts +209 -0
- package/src/__tests__/speckit-parser.test.ts +219 -0
- package/src/__tests__/speckit-spec-parser.test.ts +120 -0
- package/src/aps-markdown/__tests__/__fixtures__/simple-leaf.aps.md +17 -0
- package/src/aps-markdown/__tests__/adapter.test.ts +393 -0
- package/src/aps-markdown/adapter.ts +455 -0
- package/src/aps-markdown/index.ts +8 -0
- package/src/base/__tests__/registry.test.ts +515 -0
- package/src/base/file-discovery.ts +305 -0
- package/src/base/index.ts +10 -0
- package/src/base/registry.ts +263 -0
- package/src/base/testing.ts +334 -0
- package/src/base/types.ts +342 -0
- package/src/base/utils.ts +306 -0
- package/src/bmad/format-adapter.ts +227 -0
- package/src/bmad/index.ts +21 -0
- package/src/bmad/parser.ts +224 -0
- package/src/bmad/serializer.ts +206 -0
- package/src/bmad/types.ts +135 -0
- package/src/bmad/utils.ts +575 -0
- package/src/common/index.ts +2 -0
- package/src/common/registry.ts +72 -0
- package/src/common/types.ts +84 -0
- package/src/generic/__tests__/serializer.test.ts +167 -0
- package/src/generic/format-adapter.ts +200 -0
- package/src/generic/index.ts +11 -0
- package/src/generic/parser.ts +129 -0
- package/src/generic/serializer.ts +134 -0
- package/src/generic/types.ts +53 -0
- package/src/generic/utils.ts +270 -0
- package/src/index.ts +48 -0
- package/src/speckit/export.ts +489 -0
- package/src/speckit/format-adapter.ts +595 -0
- package/src/speckit/import-v2.ts +445 -0
- package/src/speckit/import.ts +305 -0
- package/src/speckit/index.ts +4 -0
- package/src/speckit/parser.ts +351 -0
- package/src/speckit/parsers/plan-parser.ts +342 -0
- package/src/speckit/parsers/spec-parser.ts +379 -0
- package/src/speckit/parsers/tasks-parser.ts +246 -0
- package/tsconfig.json +26 -0
- package/tsconfig.lib.json +21 -0
- package/tsconfig.lib.tsbuildinfo +1 -0
- package/tsconfig.spec.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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,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
|
+
}
|