@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.
Files changed (121) hide show
  1. package/AGENTS.md +155 -0
  2. package/LICENSE +14 -0
  3. package/README.md +57 -0
  4. package/TODO.md +40 -0
  5. package/dist/filter/context-bundle.d.ts +81 -0
  6. package/dist/filter/context-bundle.d.ts.map +1 -0
  7. package/dist/filter/context-bundle.js +230 -0
  8. package/dist/filter/index.d.ts +85 -0
  9. package/dist/filter/index.d.ts.map +1 -0
  10. package/dist/filter/index.js +169 -0
  11. package/dist/index.d.ts +16 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +15 -0
  14. package/dist/loader/index.d.ts +80 -0
  15. package/dist/loader/index.d.ts.map +1 -0
  16. package/dist/loader/index.js +253 -0
  17. package/dist/parser/index.d.ts +24 -0
  18. package/dist/parser/index.d.ts.map +1 -0
  19. package/dist/parser/index.js +22 -0
  20. package/dist/parser/parse-document.d.ts +17 -0
  21. package/dist/parser/parse-document.d.ts.map +1 -0
  22. package/dist/parser/parse-document.js +219 -0
  23. package/dist/parser/parse-index.d.ts +31 -0
  24. package/dist/parser/parse-index.d.ts.map +1 -0
  25. package/dist/parser/parse-index.js +251 -0
  26. package/dist/parser/parse-task.d.ts +30 -0
  27. package/dist/parser/parse-task.d.ts.map +1 -0
  28. package/dist/parser/parse-task.js +261 -0
  29. package/dist/state/index.d.ts +307 -0
  30. package/dist/state/index.d.ts.map +1 -0
  31. package/dist/state/index.js +689 -0
  32. package/dist/templates/generator.d.ts +71 -0
  33. package/dist/templates/generator.d.ts.map +1 -0
  34. package/dist/templates/generator.js +723 -0
  35. package/dist/templates/index.d.ts +5 -0
  36. package/dist/templates/index.d.ts.map +1 -0
  37. package/dist/templates/index.js +4 -0
  38. package/dist/types/index.d.ts +131 -0
  39. package/dist/types/index.d.ts.map +1 -0
  40. package/dist/types/index.js +107 -0
  41. package/dist/validator/index.d.ts +83 -0
  42. package/dist/validator/index.d.ts.map +1 -0
  43. package/dist/validator/index.js +611 -0
  44. package/docs/APS-Anvil-Integration.md +750 -0
  45. package/docs/APS-Conventions.md +635 -0
  46. package/docs/APS-NonGoals.md +455 -0
  47. package/docs/APS-Planning-Spec-v0.1.md +362 -0
  48. package/examples/README.md +170 -0
  49. package/examples/feature-auth.aps.md +87 -0
  50. package/examples/refactor-error-handling.aps.md +119 -0
  51. package/examples/system-ecommerce/APS.md +57 -0
  52. package/examples/system-ecommerce/modules/auth.aps.md +38 -0
  53. package/examples/system-ecommerce/modules/cart.aps.md +53 -0
  54. package/examples/system-ecommerce/modules/payments.aps.md +68 -0
  55. package/examples/system-ecommerce/modules/products.aps.md +53 -0
  56. package/package.json +34 -0
  57. package/project.json +37 -0
  58. package/scripts/generate-templates.js +33 -0
  59. package/src/filter/context-bundle.ts +312 -0
  60. package/src/filter/filter.test.ts +317 -0
  61. package/src/filter/index.ts +249 -0
  62. package/src/index.ts +16 -0
  63. package/src/loader/index.ts +364 -0
  64. package/src/loader/loader.test.ts +224 -0
  65. package/src/parser/__fixtures__/invalid-task-id-not-padded.aps.md +7 -0
  66. package/src/parser/__fixtures__/invalid-task-id.aps.md +8 -0
  67. package/src/parser/__fixtures__/minimal-task.aps.md +7 -0
  68. package/src/parser/__fixtures__/non-scope-hyphenated.aps.md +10 -0
  69. package/src/parser/__fixtures__/simple-index.aps.md +35 -0
  70. package/src/parser/__fixtures__/simple-plan.aps.md +19 -0
  71. package/src/parser/index.ts +30 -0
  72. package/src/parser/parse-document.test.ts +603 -0
  73. package/src/parser/parse-document.ts +262 -0
  74. package/src/parser/parse-index.test.ts +316 -0
  75. package/src/parser/parse-index.ts +298 -0
  76. package/src/parser/parse-task.test.ts +476 -0
  77. package/src/parser/parse-task.ts +325 -0
  78. package/src/state/__fixtures__/invalid-plan.aps.md +9 -0
  79. package/src/state/__fixtures__/test-plan.aps.md +20 -0
  80. package/src/state/index.ts +879 -0
  81. package/src/state/state.test.ts +645 -0
  82. package/src/templates/generator.test.ts +378 -0
  83. package/src/templates/generator.ts +776 -0
  84. package/src/templates/index.ts +5 -0
  85. package/src/types/index.ts +168 -0
  86. package/src/validator/__fixtures__/broken-links.aps.md +10 -0
  87. package/src/validator/__fixtures__/circular-deps-index.aps.md +26 -0
  88. package/src/validator/__fixtures__/circular-modules/module-a.aps.md +9 -0
  89. package/src/validator/__fixtures__/circular-modules/module-b.aps.md +9 -0
  90. package/src/validator/__fixtures__/circular-modules/module-c.aps.md +9 -0
  91. package/src/validator/__fixtures__/dup-modules/module-a.aps.md +9 -0
  92. package/src/validator/__fixtures__/dup-modules/module-b.aps.md +9 -0
  93. package/src/validator/__fixtures__/duplicate-ids-index.aps.md +15 -0
  94. package/src/validator/__fixtures__/invalid-task-id.aps.md +17 -0
  95. package/src/validator/__fixtures__/missing-confidence.aps.md +9 -0
  96. package/src/validator/__fixtures__/missing-h1.aps.md +5 -0
  97. package/src/validator/__fixtures__/missing-intent.aps.md +9 -0
  98. package/src/validator/__fixtures__/missing-modules-section.aps.md +7 -0
  99. package/src/validator/__fixtures__/missing-tasks-section.aps.md +7 -0
  100. package/src/validator/__fixtures__/modules/auth.aps.md +17 -0
  101. package/src/validator/__fixtures__/modules/payments.aps.md +13 -0
  102. package/src/validator/__fixtures__/scope-mismatch.aps.md +14 -0
  103. package/src/validator/__fixtures__/valid-index.aps.md +24 -0
  104. package/src/validator/__fixtures__/valid-leaf.aps.md +22 -0
  105. package/src/validator/index.ts +776 -0
  106. package/src/validator/validator.test.ts +269 -0
  107. package/templates/index-full.md +94 -0
  108. package/templates/index-minimal.md +16 -0
  109. package/templates/index-template.md +63 -0
  110. package/templates/leaf-full.md +76 -0
  111. package/templates/leaf-minimal.md +14 -0
  112. package/templates/leaf-template.md +55 -0
  113. package/templates/simple-full.md +56 -0
  114. package/templates/simple-minimal.md +14 -0
  115. package/templates/simple-template.md +30 -0
  116. package/tsconfig.json +19 -0
  117. package/tsconfig.lib.json +14 -0
  118. package/tsconfig.lib.tsbuildinfo +1 -0
  119. package/tsconfig.spec.json +9 -0
  120. package/tsconfig.tsbuildinfo +1 -0
  121. 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
+ });