@eddacraft/anvil-aps 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 +155 -0
- package/LICENSE +14 -0
- package/README.md +57 -0
- package/TODO.md +40 -0
- package/dist/filter/context-bundle.d.ts +81 -0
- package/dist/filter/context-bundle.d.ts.map +1 -0
- package/dist/filter/context-bundle.js +230 -0
- package/dist/filter/index.d.ts +85 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +169 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/loader/index.d.ts +80 -0
- package/dist/loader/index.d.ts.map +1 -0
- package/dist/loader/index.js +253 -0
- package/dist/parser/index.d.ts +24 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +22 -0
- package/dist/parser/parse-document.d.ts +17 -0
- package/dist/parser/parse-document.d.ts.map +1 -0
- package/dist/parser/parse-document.js +219 -0
- package/dist/parser/parse-index.d.ts +31 -0
- package/dist/parser/parse-index.d.ts.map +1 -0
- package/dist/parser/parse-index.js +251 -0
- package/dist/parser/parse-task.d.ts +30 -0
- package/dist/parser/parse-task.d.ts.map +1 -0
- package/dist/parser/parse-task.js +261 -0
- package/dist/state/index.d.ts +307 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +689 -0
- package/dist/templates/generator.d.ts +71 -0
- package/dist/templates/generator.d.ts.map +1 -0
- package/dist/templates/generator.js +723 -0
- package/dist/templates/index.d.ts +5 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +4 -0
- package/dist/types/index.d.ts +131 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +107 -0
- package/dist/validator/index.d.ts +83 -0
- package/dist/validator/index.d.ts.map +1 -0
- package/dist/validator/index.js +611 -0
- package/docs/APS-Anvil-Integration.md +750 -0
- package/docs/APS-Conventions.md +635 -0
- package/docs/APS-NonGoals.md +455 -0
- package/docs/APS-Planning-Spec-v0.1.md +362 -0
- package/examples/README.md +170 -0
- package/examples/feature-auth.aps.md +87 -0
- package/examples/refactor-error-handling.aps.md +119 -0
- package/examples/system-ecommerce/APS.md +57 -0
- package/examples/system-ecommerce/modules/auth.aps.md +38 -0
- package/examples/system-ecommerce/modules/cart.aps.md +53 -0
- package/examples/system-ecommerce/modules/payments.aps.md +68 -0
- package/examples/system-ecommerce/modules/products.aps.md +53 -0
- package/package.json +34 -0
- package/project.json +37 -0
- package/scripts/generate-templates.js +33 -0
- package/src/filter/context-bundle.ts +312 -0
- package/src/filter/filter.test.ts +317 -0
- package/src/filter/index.ts +249 -0
- package/src/index.ts +16 -0
- package/src/loader/index.ts +364 -0
- package/src/loader/loader.test.ts +224 -0
- package/src/parser/__fixtures__/invalid-task-id-not-padded.aps.md +7 -0
- package/src/parser/__fixtures__/invalid-task-id.aps.md +8 -0
- package/src/parser/__fixtures__/minimal-task.aps.md +7 -0
- package/src/parser/__fixtures__/non-scope-hyphenated.aps.md +10 -0
- package/src/parser/__fixtures__/simple-index.aps.md +35 -0
- package/src/parser/__fixtures__/simple-plan.aps.md +19 -0
- package/src/parser/index.ts +30 -0
- package/src/parser/parse-document.test.ts +603 -0
- package/src/parser/parse-document.ts +262 -0
- package/src/parser/parse-index.test.ts +316 -0
- package/src/parser/parse-index.ts +298 -0
- package/src/parser/parse-task.test.ts +476 -0
- package/src/parser/parse-task.ts +325 -0
- package/src/state/__fixtures__/invalid-plan.aps.md +9 -0
- package/src/state/__fixtures__/test-plan.aps.md +20 -0
- package/src/state/index.ts +879 -0
- package/src/state/state.test.ts +645 -0
- package/src/templates/generator.test.ts +378 -0
- package/src/templates/generator.ts +776 -0
- package/src/templates/index.ts +5 -0
- package/src/types/index.ts +168 -0
- package/src/validator/__fixtures__/broken-links.aps.md +10 -0
- package/src/validator/__fixtures__/circular-deps-index.aps.md +26 -0
- package/src/validator/__fixtures__/circular-modules/module-a.aps.md +9 -0
- package/src/validator/__fixtures__/circular-modules/module-b.aps.md +9 -0
- package/src/validator/__fixtures__/circular-modules/module-c.aps.md +9 -0
- package/src/validator/__fixtures__/dup-modules/module-a.aps.md +9 -0
- package/src/validator/__fixtures__/dup-modules/module-b.aps.md +9 -0
- package/src/validator/__fixtures__/duplicate-ids-index.aps.md +15 -0
- package/src/validator/__fixtures__/invalid-task-id.aps.md +17 -0
- package/src/validator/__fixtures__/missing-confidence.aps.md +9 -0
- package/src/validator/__fixtures__/missing-h1.aps.md +5 -0
- package/src/validator/__fixtures__/missing-intent.aps.md +9 -0
- package/src/validator/__fixtures__/missing-modules-section.aps.md +7 -0
- package/src/validator/__fixtures__/missing-tasks-section.aps.md +7 -0
- package/src/validator/__fixtures__/modules/auth.aps.md +17 -0
- package/src/validator/__fixtures__/modules/payments.aps.md +13 -0
- package/src/validator/__fixtures__/scope-mismatch.aps.md +14 -0
- package/src/validator/__fixtures__/valid-index.aps.md +24 -0
- package/src/validator/__fixtures__/valid-leaf.aps.md +22 -0
- package/src/validator/index.ts +776 -0
- package/src/validator/validator.test.ts +269 -0
- package/templates/index-full.md +94 -0
- package/templates/index-minimal.md +16 -0
- package/templates/index-template.md +63 -0
- package/templates/leaf-full.md +76 -0
- package/templates/leaf-minimal.md +14 -0
- package/templates/leaf-template.md +55 -0
- package/templates/simple-full.md +56 -0
- package/templates/simple-minimal.md +14 -0
- package/templates/simple-template.md +30 -0
- package/tsconfig.json +19 -0
- package/tsconfig.lib.json +14 -0
- package/tsconfig.lib.tsbuildinfo +1 -0
- package/tsconfig.spec.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document parsing utilities
|
|
3
|
+
* Converts APS Markdown documents to structured data
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { unified } from 'unified';
|
|
7
|
+
import remarkParse from 'remark-parse';
|
|
8
|
+
import { visit } from 'unist-util-visit';
|
|
9
|
+
import type { Root, Heading, Paragraph, List, Strong } from 'mdast';
|
|
10
|
+
import { parseTask } from './parse-task.js';
|
|
11
|
+
import { ParseError, type Task, type ParsedDocument, type ModuleMetadata } from '../types/index.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse an APS leaf spec document from Markdown content
|
|
15
|
+
*
|
|
16
|
+
* This function parses leaf specs (documents with tasks).
|
|
17
|
+
* For index files (documents with modules), use `parseIndex` instead.
|
|
18
|
+
*
|
|
19
|
+
* @param content - Markdown content of a leaf spec
|
|
20
|
+
* @param sourcePath - Optional source file path for error reporting
|
|
21
|
+
* @returns Parsed document with tasks
|
|
22
|
+
*/
|
|
23
|
+
export async function parseDocument(content: string, sourcePath?: string): Promise<ParsedDocument> {
|
|
24
|
+
// Parse Markdown to AST
|
|
25
|
+
const processor = unified().use(remarkParse);
|
|
26
|
+
const ast = processor.parse(content) as Root;
|
|
27
|
+
|
|
28
|
+
// Extract document structure
|
|
29
|
+
const structure = extractStructure(ast, sourcePath);
|
|
30
|
+
|
|
31
|
+
// Validate structure
|
|
32
|
+
if (!structure.title) {
|
|
33
|
+
throw new ParseError('Document must have an H1 title', sourcePath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
title: structure.title,
|
|
38
|
+
metadata: structure.metadata,
|
|
39
|
+
tasks: structure.tasks,
|
|
40
|
+
sourcePath,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract document structure from AST
|
|
46
|
+
*/
|
|
47
|
+
interface DocumentStructure {
|
|
48
|
+
title: string | null;
|
|
49
|
+
metadata?: ModuleMetadata;
|
|
50
|
+
tasks: Task[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type TaskHeading = { heading: Heading; lineNumber: number };
|
|
54
|
+
|
|
55
|
+
function extractStructure(ast: Root, sourcePath?: string): DocumentStructure {
|
|
56
|
+
const structure: DocumentStructure = {
|
|
57
|
+
title: null,
|
|
58
|
+
tasks: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let currentSection: 'root' | 'tasks' = 'root';
|
|
62
|
+
let currentTaskHeading: TaskHeading | null = null;
|
|
63
|
+
let currentTaskContent: Array<Paragraph | List> = [];
|
|
64
|
+
|
|
65
|
+
visit(ast, (node, index, parent) => {
|
|
66
|
+
if (node.type === 'heading') {
|
|
67
|
+
const heading = node as Heading;
|
|
68
|
+
|
|
69
|
+
// H1: Document title
|
|
70
|
+
if (heading.depth === 1) {
|
|
71
|
+
structure.title = extractPlainText(heading);
|
|
72
|
+
|
|
73
|
+
// Check next sibling for metadata line
|
|
74
|
+
if (parent && index !== null && index !== undefined) {
|
|
75
|
+
const nextSibling = (parent as Root).children[index + 1];
|
|
76
|
+
if (nextSibling && nextSibling.type === 'paragraph') {
|
|
77
|
+
structure.metadata = parseMetadataLine(nextSibling as Paragraph);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// H2: Section headers
|
|
83
|
+
if (heading.depth === 2) {
|
|
84
|
+
// Save previous task if any
|
|
85
|
+
if (currentTaskHeading) {
|
|
86
|
+
saveTask(currentTaskHeading, currentTaskContent, sourcePath, structure);
|
|
87
|
+
currentTaskHeading = null;
|
|
88
|
+
currentTaskContent = [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sectionTitle = extractPlainText(heading).toLowerCase();
|
|
92
|
+
if (sectionTitle === 'tasks') {
|
|
93
|
+
currentSection = 'tasks';
|
|
94
|
+
} else {
|
|
95
|
+
currentSection = 'root';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// H3: Task headings
|
|
100
|
+
if (heading.depth === 3 && currentSection === 'tasks') {
|
|
101
|
+
// Save previous task if any
|
|
102
|
+
if (currentTaskHeading) {
|
|
103
|
+
saveTask(currentTaskHeading, currentTaskContent, sourcePath, structure);
|
|
104
|
+
currentTaskHeading = null;
|
|
105
|
+
currentTaskContent = [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Start new task
|
|
109
|
+
currentTaskHeading = {
|
|
110
|
+
heading,
|
|
111
|
+
lineNumber: node.position?.start.line ?? 0,
|
|
112
|
+
};
|
|
113
|
+
currentTaskContent = [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Collect content for current task
|
|
118
|
+
if (currentTaskHeading && (node.type === 'paragraph' || node.type === 'list')) {
|
|
119
|
+
currentTaskContent.push(node as Paragraph | List);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Save last task if any
|
|
124
|
+
if (currentTaskHeading) {
|
|
125
|
+
saveTask(currentTaskHeading, currentTaskContent, sourcePath, structure);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return structure;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Helper to save a task from heading and content
|
|
133
|
+
*/
|
|
134
|
+
function saveTask(
|
|
135
|
+
taskHeading: TaskHeading,
|
|
136
|
+
taskContent: Array<Paragraph | List>,
|
|
137
|
+
sourcePath: string | undefined,
|
|
138
|
+
structure: DocumentStructure
|
|
139
|
+
): void {
|
|
140
|
+
try {
|
|
141
|
+
const task = parseTask(taskHeading.heading, taskContent, sourcePath, taskHeading.lineNumber);
|
|
142
|
+
structure.tasks.push(task);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof ParseError) {
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
throw new ParseError(
|
|
148
|
+
`Failed to parse task: ${error instanceof Error ? error.message : String(error)}`,
|
|
149
|
+
sourcePath
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Extract plain text from heading or paragraph node
|
|
156
|
+
*/
|
|
157
|
+
function extractPlainText(node: Heading | Paragraph): string {
|
|
158
|
+
let text = '';
|
|
159
|
+
|
|
160
|
+
visit(node, 'text', (textNode) => {
|
|
161
|
+
text += (textNode as { value: string }).value;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return text;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse module metadata line (immediately after H1)
|
|
169
|
+
* Format: **Scope:** AUTH **Owner:** @alice **Priority:** high
|
|
170
|
+
*/
|
|
171
|
+
function parseMetadataLine(para: Paragraph): ModuleMetadata {
|
|
172
|
+
const metadata: ModuleMetadata = {};
|
|
173
|
+
let currentKey = '';
|
|
174
|
+
let currentValue = '';
|
|
175
|
+
|
|
176
|
+
for (const child of para.children) {
|
|
177
|
+
if (child.type === 'strong') {
|
|
178
|
+
// Save previous field (even if value is empty, so handlers like Packages can default)
|
|
179
|
+
if (currentKey) {
|
|
180
|
+
assignMetadataField(metadata, currentKey, currentValue.trim());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Extract new field key
|
|
184
|
+
let strongText = '';
|
|
185
|
+
visit(child as Strong, 'text', (textNode) => {
|
|
186
|
+
strongText += (textNode as { value: string }).value;
|
|
187
|
+
});
|
|
188
|
+
const match = strongText.match(/^(\w+):$/);
|
|
189
|
+
if (match) {
|
|
190
|
+
currentKey = match[1];
|
|
191
|
+
currentValue = '';
|
|
192
|
+
}
|
|
193
|
+
} else if (child.type === 'text' && currentKey) {
|
|
194
|
+
currentValue += (child as { value: string }).value;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Save last field (even if value is empty)
|
|
199
|
+
if (currentKey) {
|
|
200
|
+
assignMetadataField(metadata, currentKey, currentValue.trim());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return metadata;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Assign metadata field value
|
|
208
|
+
*/
|
|
209
|
+
function assignMetadataField(metadata: ModuleMetadata, key: string, value: string): void {
|
|
210
|
+
switch (key) {
|
|
211
|
+
case 'Scope':
|
|
212
|
+
case 'ID':
|
|
213
|
+
// Support both 'Scope:' and 'ID:' per current APS spec
|
|
214
|
+
metadata.scope = value;
|
|
215
|
+
break;
|
|
216
|
+
case 'Owner':
|
|
217
|
+
metadata.owner = value;
|
|
218
|
+
break;
|
|
219
|
+
case 'Status': {
|
|
220
|
+
// Normalise status values to match ModuleStatusSchema
|
|
221
|
+
// Legacy values are mapped to canonical equivalents: Draft→Proposed, Complete→Done
|
|
222
|
+
const statusMap: Record<string, ModuleMetadata['status']> = {
|
|
223
|
+
Draft: 'Proposed',
|
|
224
|
+
Proposed: 'Proposed',
|
|
225
|
+
Ready: 'Ready',
|
|
226
|
+
'In Progress': 'In Progress',
|
|
227
|
+
Complete: 'Done',
|
|
228
|
+
Done: 'Done',
|
|
229
|
+
Blocked: 'Blocked',
|
|
230
|
+
};
|
|
231
|
+
const mapped = statusMap[value.trim()];
|
|
232
|
+
if (mapped) {
|
|
233
|
+
metadata.status = mapped;
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case 'Priority':
|
|
238
|
+
if (value === 'low' || value === 'medium' || value === 'high') {
|
|
239
|
+
metadata.priority = value;
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
case 'Tags':
|
|
243
|
+
metadata.tags = value.split(',').map((t) => t.trim());
|
|
244
|
+
break;
|
|
245
|
+
case 'Dependencies':
|
|
246
|
+
metadata.dependencies = value.split(',').map((d) => d.trim());
|
|
247
|
+
break;
|
|
248
|
+
case 'Packages': {
|
|
249
|
+
// Monorepo support: list of affected packages
|
|
250
|
+
const trimmed = value.trim();
|
|
251
|
+
if (!trimmed || trimmed.toLowerCase() === '(none)') {
|
|
252
|
+
metadata.packages = [];
|
|
253
|
+
} else {
|
|
254
|
+
metadata.packages = trimmed
|
|
255
|
+
.split(',')
|
|
256
|
+
.map((p) => p.trim())
|
|
257
|
+
.filter((p) => p.length > 0);
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for parse-index module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { parseIndex } from './parse-index.js';
|
|
10
|
+
import { ParseError } from '../types/index.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const FIXTURES_DIR = join(__dirname, '__fixtures__');
|
|
14
|
+
const EXAMPLES_DIR = join(__dirname, '../../examples');
|
|
15
|
+
|
|
16
|
+
async function loadFixture(filename: string): Promise<string> {
|
|
17
|
+
return fs.readFile(join(FIXTURES_DIR, filename), 'utf-8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('parseIndex', () => {
|
|
21
|
+
describe('basic parsing', () => {
|
|
22
|
+
it('should parse a simple index file', async () => {
|
|
23
|
+
const content = await loadFixture('simple-index.aps.md');
|
|
24
|
+
const index = await parseIndex(content, 'simple-index.aps.md');
|
|
25
|
+
|
|
26
|
+
expect(index.title).toBe('Simple Plan');
|
|
27
|
+
expect(index.overview).toBe('A simple plan with two modules for testing.');
|
|
28
|
+
expect(index.modules).toHaveLength(2);
|
|
29
|
+
expect(index.sourcePath).toBe('simple-index.aps.md');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should parse module metadata correctly', async () => {
|
|
33
|
+
const content = await loadFixture('simple-index.aps.md');
|
|
34
|
+
const index = await parseIndex(content);
|
|
35
|
+
|
|
36
|
+
// First module (auth)
|
|
37
|
+
expect(index.modules[0]).toEqual({
|
|
38
|
+
id: 'auth',
|
|
39
|
+
path: './modules/auth.aps.md',
|
|
40
|
+
scope: 'AUTH',
|
|
41
|
+
owner: '@alice',
|
|
42
|
+
priority: 'high',
|
|
43
|
+
tags: ['security', 'core'],
|
|
44
|
+
dependencies: [],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Second module (api)
|
|
48
|
+
expect(index.modules[1]).toEqual({
|
|
49
|
+
id: 'api',
|
|
50
|
+
path: './modules/api.aps.md',
|
|
51
|
+
scope: 'API',
|
|
52
|
+
owner: '@bob',
|
|
53
|
+
priority: 'medium',
|
|
54
|
+
tags: ['backend'],
|
|
55
|
+
dependencies: ['auth'],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should parse open questions', async () => {
|
|
60
|
+
const content = await loadFixture('simple-index.aps.md');
|
|
61
|
+
const index = await parseIndex(content);
|
|
62
|
+
|
|
63
|
+
expect(index.openQuestions).toEqual([
|
|
64
|
+
'Should we add rate limiting?',
|
|
65
|
+
'What authentication method to use?',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should parse decisions', async () => {
|
|
70
|
+
const content = await loadFixture('simple-index.aps.md');
|
|
71
|
+
const index = await parseIndex(content);
|
|
72
|
+
|
|
73
|
+
expect(index.decisions).toEqual([
|
|
74
|
+
'Using JWT tokens (decided 2025-01-15)',
|
|
75
|
+
'PostgreSQL database (decided 2025-01-10)',
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('real examples', () => {
|
|
81
|
+
it('should parse system-ecommerce index file', async () => {
|
|
82
|
+
const content = await fs.readFile(join(EXAMPLES_DIR, 'system-ecommerce/APS.md'), 'utf-8');
|
|
83
|
+
const index = await parseIndex(content, 'system-ecommerce/APS.md');
|
|
84
|
+
|
|
85
|
+
expect(index.title).toBe('E-commerce Platform MVP');
|
|
86
|
+
expect(index.modules).toHaveLength(4);
|
|
87
|
+
|
|
88
|
+
// Check module IDs
|
|
89
|
+
expect(index.modules.map((m) => m.id)).toEqual(['auth', 'products', 'cart', 'payments']);
|
|
90
|
+
|
|
91
|
+
// Check auth module
|
|
92
|
+
expect(index.modules[0]).toMatchObject({
|
|
93
|
+
id: 'auth',
|
|
94
|
+
path: './modules/auth.aps.md',
|
|
95
|
+
scope: 'AUTH',
|
|
96
|
+
owner: '@alice',
|
|
97
|
+
priority: 'high',
|
|
98
|
+
dependencies: [],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Check payments module dependencies
|
|
102
|
+
expect(index.modules[3]).toMatchObject({
|
|
103
|
+
id: 'payments',
|
|
104
|
+
dependencies: ['auth', 'cart'],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Check open questions exist
|
|
108
|
+
expect(index.openQuestions).toHaveLength(3);
|
|
109
|
+
|
|
110
|
+
// Check decisions exist
|
|
111
|
+
expect(index.decisions).toHaveLength(4);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('error handling', () => {
|
|
116
|
+
it('should throw ParseError for index without title', async () => {
|
|
117
|
+
const content = '## Modules\n\n### auth\n- **Path:** ./auth.aps.md';
|
|
118
|
+
|
|
119
|
+
await expect(parseIndex(content, 'no-title.md')).rejects.toThrow(ParseError);
|
|
120
|
+
await expect(parseIndex(content, 'no-title.md')).rejects.toThrow(/must have an H1 title/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should handle index with no modules', async () => {
|
|
124
|
+
const content = '# Empty Plan\n\n## Overview\n\nJust an overview.';
|
|
125
|
+
|
|
126
|
+
const index = await parseIndex(content);
|
|
127
|
+
|
|
128
|
+
expect(index.title).toBe('Empty Plan');
|
|
129
|
+
expect(index.modules).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('ID field parsing', () => {
|
|
134
|
+
it('should parse ID field as alias for Scope', async () => {
|
|
135
|
+
const content = `# Test Plan
|
|
136
|
+
|
|
137
|
+
## Modules
|
|
138
|
+
|
|
139
|
+
### auth
|
|
140
|
+
|
|
141
|
+
- **Path:** [./auth.aps.md](./auth.aps.md)
|
|
142
|
+
- **ID:** AUTH
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
const index = await parseIndex(content);
|
|
146
|
+
expect(index.modules[0].scope).toBe('AUTH');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should parse Scope field (legacy) the same as ID', async () => {
|
|
150
|
+
const content = `# Test Plan
|
|
151
|
+
|
|
152
|
+
## Modules
|
|
153
|
+
|
|
154
|
+
### auth
|
|
155
|
+
|
|
156
|
+
- **Path:** [./auth.aps.md](./auth.aps.md)
|
|
157
|
+
- **Scope:** AUTH
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
const index = await parseIndex(content);
|
|
161
|
+
expect(index.modules[0].scope).toBe('AUTH');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('status normalization', () => {
|
|
166
|
+
it('should normalize legacy Draft to Proposed', async () => {
|
|
167
|
+
const content = `# Test Plan
|
|
168
|
+
|
|
169
|
+
## Modules
|
|
170
|
+
|
|
171
|
+
### auth
|
|
172
|
+
|
|
173
|
+
- **Path:** [./auth.aps.md](./auth.aps.md)
|
|
174
|
+
- **Status:** Draft
|
|
175
|
+
`;
|
|
176
|
+
|
|
177
|
+
const index = await parseIndex(content);
|
|
178
|
+
expect(index.modules[0].status).toBe('Proposed');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should normalize legacy Complete to Done', async () => {
|
|
182
|
+
const content = `# Test Plan
|
|
183
|
+
|
|
184
|
+
## Modules
|
|
185
|
+
|
|
186
|
+
### auth
|
|
187
|
+
|
|
188
|
+
- **Path:** [./auth.aps.md](./auth.aps.md)
|
|
189
|
+
- **Status:** Complete
|
|
190
|
+
`;
|
|
191
|
+
|
|
192
|
+
const index = await parseIndex(content);
|
|
193
|
+
expect(index.modules[0].status).toBe('Done');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should accept all valid status values', async () => {
|
|
197
|
+
for (const [input, expected] of [
|
|
198
|
+
['Proposed', 'Proposed'],
|
|
199
|
+
['Ready', 'Ready'],
|
|
200
|
+
['In Progress', 'In Progress'],
|
|
201
|
+
['Done', 'Done'],
|
|
202
|
+
['Blocked', 'Blocked'],
|
|
203
|
+
] as const) {
|
|
204
|
+
const content = `# Test Plan
|
|
205
|
+
|
|
206
|
+
## Modules
|
|
207
|
+
|
|
208
|
+
### mod
|
|
209
|
+
|
|
210
|
+
- **Path:** [./mod.aps.md](./mod.aps.md)
|
|
211
|
+
- **Status:** ${input}
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
const index = await parseIndex(content);
|
|
215
|
+
expect(index.modules[0].status).toBe(expected);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('packages parsing', () => {
|
|
221
|
+
it('should parse comma-separated Packages field', async () => {
|
|
222
|
+
const content = `# Test Plan
|
|
223
|
+
|
|
224
|
+
## Modules
|
|
225
|
+
|
|
226
|
+
### auth
|
|
227
|
+
|
|
228
|
+
- **Path:** [./auth.aps.md](./auth.aps.md)
|
|
229
|
+
- **Packages:** @app/core, @app/utils
|
|
230
|
+
`;
|
|
231
|
+
|
|
232
|
+
const index = await parseIndex(content);
|
|
233
|
+
expect(index.modules[0].packages).toEqual(['@app/core', '@app/utils']);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should handle Packages: (none) as empty array', async () => {
|
|
237
|
+
const content = `# Test Plan
|
|
238
|
+
|
|
239
|
+
## Modules
|
|
240
|
+
|
|
241
|
+
### auth
|
|
242
|
+
|
|
243
|
+
- **Path:** [./auth.aps.md](./auth.aps.md)
|
|
244
|
+
- **Packages:** (none)
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
const index = await parseIndex(content);
|
|
248
|
+
expect(index.modules[0].packages).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should handle empty Packages value as empty array', async () => {
|
|
252
|
+
const content = `# Test Plan
|
|
253
|
+
|
|
254
|
+
## Modules
|
|
255
|
+
|
|
256
|
+
### auth
|
|
257
|
+
|
|
258
|
+
- **Path:** [./auth.aps.md](./auth.aps.md)
|
|
259
|
+
- **Packages:**
|
|
260
|
+
`;
|
|
261
|
+
|
|
262
|
+
const index = await parseIndex(content);
|
|
263
|
+
expect(index.modules[0].packages).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('edge cases', () => {
|
|
268
|
+
it('should handle module with minimal metadata', async () => {
|
|
269
|
+
const content = `# Minimal
|
|
270
|
+
|
|
271
|
+
## Modules
|
|
272
|
+
|
|
273
|
+
### simple
|
|
274
|
+
|
|
275
|
+
- **Path:** [./simple.aps.md](./simple.aps.md)
|
|
276
|
+
`;
|
|
277
|
+
|
|
278
|
+
const index = await parseIndex(content);
|
|
279
|
+
|
|
280
|
+
expect(index.modules[0]).toEqual({
|
|
281
|
+
id: 'simple',
|
|
282
|
+
path: './simple.aps.md',
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should handle empty dependencies', async () => {
|
|
287
|
+
const content = `# Test
|
|
288
|
+
|
|
289
|
+
## Modules
|
|
290
|
+
|
|
291
|
+
### mod
|
|
292
|
+
|
|
293
|
+
- **Path:** [./mod.aps.md](./mod.aps.md)
|
|
294
|
+
- **Dependencies:** (none)
|
|
295
|
+
`;
|
|
296
|
+
|
|
297
|
+
const index = await parseIndex(content);
|
|
298
|
+
expect(index.modules[0].dependencies).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should handle multiple tags', async () => {
|
|
302
|
+
const content = `# Test
|
|
303
|
+
|
|
304
|
+
## Modules
|
|
305
|
+
|
|
306
|
+
### mod
|
|
307
|
+
|
|
308
|
+
- **Path:** [./mod.aps.md](./mod.aps.md)
|
|
309
|
+
- **Tags:** one, two, three, four
|
|
310
|
+
`;
|
|
311
|
+
|
|
312
|
+
const index = await parseIndex(content);
|
|
313
|
+
expect(index.modules[0].tags).toEqual(['one', 'two', 'three', 'four']);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|