@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,476 @@
1
+ /**
2
+ * Tests for parse-task module
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { parseTaskHeading, parseTaskFields, parseTask } from './parse-task.js';
7
+ import { ParseError } from '../types/index.js';
8
+ import type { Heading, Paragraph, Strong, Text, List, ListItem, InlineCode, Break } from 'mdast';
9
+
10
+ // --- Helpers to build mdast nodes ---
11
+
12
+ function heading(depth: 1 | 2 | 3, text: string): Heading {
13
+ return { type: 'heading', depth, children: [{ type: 'text', value: text }] };
14
+ }
15
+
16
+ function paragraph(children: Paragraph['children']): Paragraph {
17
+ return { type: 'paragraph', children };
18
+ }
19
+
20
+ function strong(text: string): Strong {
21
+ return { type: 'strong', children: [{ type: 'text', value: text }] };
22
+ }
23
+
24
+ function text(value: string): Text {
25
+ return { type: 'text', value };
26
+ }
27
+
28
+ function inlineCode(value: string): InlineCode {
29
+ return { type: 'inlineCode', value };
30
+ }
31
+
32
+ function br(): Break {
33
+ return { type: 'break' };
34
+ }
35
+
36
+ function list(items: string[]): List {
37
+ return {
38
+ type: 'list',
39
+ ordered: false,
40
+ children: items.map(
41
+ (item): ListItem => ({
42
+ type: 'listItem',
43
+ children: [paragraph([text(item)])],
44
+ })
45
+ ),
46
+ };
47
+ }
48
+
49
+ /** Build a paragraph with bold field key-value pairs: **Key:** value */
50
+ function fieldParagraph(
51
+ fields: Record<string, string | { type: 'inlineCode'; value: string }>
52
+ ): Paragraph {
53
+ const children: Paragraph['children'] = [];
54
+ const entries = Object.entries(fields);
55
+
56
+ for (let i = 0; i < entries.length; i++) {
57
+ const [key, value] = entries[i];
58
+ children.push(strong(`${key}:`));
59
+ if (typeof value === 'string') {
60
+ children.push(text(` ${value}`));
61
+ } else {
62
+ children.push(text(' '));
63
+ children.push(inlineCode(value.value));
64
+ }
65
+ if (i < entries.length - 1) {
66
+ children.push(text('\n'));
67
+ }
68
+ }
69
+
70
+ return paragraph(children);
71
+ }
72
+
73
+ // --- Tests ---
74
+
75
+ describe('parseTaskHeading', () => {
76
+ it('should parse a valid H3 heading with task ID and title', () => {
77
+ const h = heading(3, 'AUTH-001: Create user database model');
78
+ const result = parseTaskHeading(h);
79
+ expect(result).toEqual({ id: 'AUTH-001', title: 'Create user database model' });
80
+ });
81
+
82
+ it('should handle numeric scope prefixes', () => {
83
+ const h = heading(3, 'LLM2-007: Update language model');
84
+ const result = parseTaskHeading(h);
85
+ expect(result).toEqual({ id: 'LLM2-007', title: 'Update language model' });
86
+ });
87
+
88
+ it('should handle single-character scope', () => {
89
+ const h = heading(3, 'A-001: Short scope');
90
+ const result = parseTaskHeading(h);
91
+ expect(result).toEqual({ id: 'A-001', title: 'Short scope' });
92
+ });
93
+
94
+ it('should handle max-length scope (10 chars)', () => {
95
+ const h = heading(3, 'ABCDEFGHIJ-999: Max length scope');
96
+ const result = parseTaskHeading(h);
97
+ expect(result).toEqual({ id: 'ABCDEFGHIJ-999', title: 'Max length scope' });
98
+ });
99
+
100
+ it('should trim whitespace from title', () => {
101
+ const h = heading(3, 'TEST-001: Whitespace title ');
102
+ const result = parseTaskHeading(h);
103
+ expect(result).toEqual({ id: 'TEST-001', title: 'Whitespace title' });
104
+ });
105
+
106
+ it('should throw ParseError for non-H3 headings', () => {
107
+ expect(() => parseTaskHeading(heading(1, 'TEST-001: Title'))).toThrow(ParseError);
108
+ expect(() => parseTaskHeading(heading(2, 'TEST-001: Title'))).toThrow(ParseError);
109
+ expect(() => parseTaskHeading(heading(1, 'TEST-001: Title'))).toThrow(
110
+ /Task headings must be H3/
111
+ );
112
+ });
113
+
114
+ it('should throw ParseError for heading without colon separator', () => {
115
+ const h = heading(3, 'TEST-001 No colon');
116
+ expect(() => parseTaskHeading(h)).toThrow(ParseError);
117
+ expect(() => parseTaskHeading(h)).toThrow(/Invalid task heading format/);
118
+ });
119
+
120
+ it('should throw ParseError for heading without task ID', () => {
121
+ const h = heading(3, 'Just a title');
122
+ expect(() => parseTaskHeading(h)).toThrow(ParseError);
123
+ });
124
+
125
+ it('should throw ParseError for lowercase scope', () => {
126
+ const h = heading(3, 'auth-001: Lowercase scope');
127
+ expect(() => parseTaskHeading(h)).toThrow(ParseError);
128
+ });
129
+
130
+ it('should throw ParseError for non-zero-padded numbers', () => {
131
+ const h = heading(3, 'TEST-1: Not padded');
132
+ expect(() => parseTaskHeading(h)).toThrow(ParseError);
133
+ });
134
+
135
+ it('should throw ParseError for scope exceeding 10 characters', () => {
136
+ const h = heading(3, 'ABCDEFGHIJK-001: Too long scope');
137
+ expect(() => parseTaskHeading(h)).toThrow(ParseError);
138
+ });
139
+
140
+ it('should throw ParseError for empty title after colon', () => {
141
+ const h = heading(3, 'TEST-001:');
142
+ expect(() => parseTaskHeading(h)).toThrow(ParseError);
143
+ });
144
+
145
+ it('should extract text from heading with inline formatting', () => {
146
+ // Heading with strong child wrapping part of the text
147
+ const h: Heading = {
148
+ type: 'heading',
149
+ depth: 3,
150
+ children: [
151
+ { type: 'text', value: 'TEST-001: ' },
152
+ { type: 'strong', children: [{ type: 'text', value: 'Bold title' }] },
153
+ ],
154
+ };
155
+ const result = parseTaskHeading(h);
156
+ expect(result).toEqual({ id: 'TEST-001', title: 'Bold title' });
157
+ });
158
+ });
159
+
160
+ describe('parseTaskFields', () => {
161
+ it('should parse Intent field', () => {
162
+ const para = fieldParagraph({ Intent: 'Create the user model' });
163
+ const result = parseTaskFields([para], []);
164
+ expect(result.intent).toBe('Create the user model');
165
+ });
166
+
167
+ it('should parse Expected Outcome field (with space)', () => {
168
+ const para = paragraph([strong('Expected Outcome:'), text(' User model is created')]);
169
+ const result = parseTaskFields([para], []);
170
+ expect(result.expectedOutcome).toBe('User model is created');
171
+ });
172
+
173
+ it('should parse Validation field', () => {
174
+ const para = fieldParagraph({ Validation: 'pnpm test' });
175
+ const result = parseTaskFields([para], []);
176
+ expect(result.validation).toBe('pnpm test');
177
+ });
178
+
179
+ it('should parse Test field as alias for Validation', () => {
180
+ const para = fieldParagraph({ Test: 'npm run test' });
181
+ const result = parseTaskFields([para], []);
182
+ expect(result.validation).toBe('npm run test');
183
+ });
184
+
185
+ it('should parse Confidence field values', () => {
186
+ for (const level of ['low', 'medium', 'high'] as const) {
187
+ const para = fieldParagraph({ Confidence: level });
188
+ const result = parseTaskFields([para], []);
189
+ expect(result.confidence).toBe(level);
190
+ }
191
+ });
192
+
193
+ it('should default invalid confidence to medium', () => {
194
+ const para = fieldParagraph({ Confidence: 'extreme' });
195
+ const result = parseTaskFields([para], []);
196
+ expect(result.confidence).toBe('medium');
197
+ });
198
+
199
+ it('should parse Scopes as comma-separated list', () => {
200
+ const para = fieldParagraph({ Scopes: 'AUTH, DB, API' });
201
+ const result = parseTaskFields([para], []);
202
+ expect(result.scopes).toEqual(['AUTH', 'DB', 'API']);
203
+ });
204
+
205
+ it('should parse NonScope field', () => {
206
+ const para = fieldParagraph({ NonScope: 'database, external APIs' });
207
+ const result = parseTaskFields([para], []);
208
+ expect(result.nonScope).toEqual(['database', 'external APIs']);
209
+ });
210
+
211
+ it('should parse Non-scope field (hyphenated)', () => {
212
+ const para = paragraph([strong('Non-scope:'), text(' database, external APIs')]);
213
+ const result = parseTaskFields([para], []);
214
+ expect(result.nonScope).toEqual(['database', 'external APIs']);
215
+ });
216
+
217
+ it('should parse Files field', () => {
218
+ const para = fieldParagraph({ Files: 'src/model.ts, src/service.ts' });
219
+ const result = parseTaskFields([para], []);
220
+ expect(result.files).toEqual(['src/model.ts', 'src/service.ts']);
221
+ });
222
+
223
+ it('should parse Tags field', () => {
224
+ const para = fieldParagraph({ Tags: 'testing, security, storage' });
225
+ const result = parseTaskFields([para], []);
226
+ expect(result.tags).toEqual(['testing', 'security', 'storage']);
227
+ });
228
+
229
+ it('should parse Dependencies field', () => {
230
+ const para = fieldParagraph({ Dependencies: 'AUTH-001, AUTH-002' });
231
+ const result = parseTaskFields([para], []);
232
+ expect(result.dependencies).toEqual(['AUTH-001', 'AUTH-002']);
233
+ });
234
+
235
+ it('should parse Risks field', () => {
236
+ const para = fieldParagraph({ Risks: 'data loss, downtime' });
237
+ const result = parseTaskFields([para], []);
238
+ expect(result.risks).toEqual(['data loss', 'downtime']);
239
+ });
240
+
241
+ it('should parse Packages field', () => {
242
+ const para = fieldParagraph({ Packages: '@app/core, @app/utils' });
243
+ const result = parseTaskFields([para], []);
244
+ expect(result.packages).toEqual(['@app/core', '@app/utils']);
245
+ });
246
+
247
+ it('should handle Packages: (none) as empty array', () => {
248
+ const para = fieldParagraph({ Packages: '(none)' });
249
+ const result = parseTaskFields([para], []);
250
+ expect(result.packages).toEqual([]);
251
+ });
252
+
253
+ it('should handle empty Packages value as empty array', () => {
254
+ const para = paragraph([strong('Packages:'), text('')]);
255
+ const result = parseTaskFields([para], []);
256
+ expect(result.packages).toEqual([]);
257
+ });
258
+
259
+ it('should parse Link field', () => {
260
+ const para = fieldParagraph({ Link: 'https://jira.example.com/PROJ-123' });
261
+ const result = parseTaskFields([para], []);
262
+ expect(result.link).toBe('https://jira.example.com/PROJ-123');
263
+ });
264
+
265
+ it('should parse Status field', () => {
266
+ for (const status of ['open', 'locked', 'completed', 'cancelled'] as const) {
267
+ const para = fieldParagraph({ Status: status });
268
+ const result = parseTaskFields([para], []);
269
+ expect(result.status).toBe(status);
270
+ }
271
+ });
272
+
273
+ it('should default invalid status to open', () => {
274
+ const para = fieldParagraph({ Status: 'unknown' });
275
+ const result = parseTaskFields([para], []);
276
+ expect(result.status).toBe('open');
277
+ });
278
+
279
+ it('should parse inline Inputs as single-item array', () => {
280
+ const para = fieldParagraph({ Inputs: 'Database credentials' });
281
+ const result = parseTaskFields([para], []);
282
+ expect(result.inputs).toEqual(['Database credentials']);
283
+ });
284
+
285
+ it('should parse Inputs with following list', () => {
286
+ const para = paragraph([strong('Inputs:'), text('')]);
287
+ const inputList = list(['Input one', 'Input two']);
288
+ const result = parseTaskFields([para], [inputList]);
289
+ expect(result.inputs).toEqual(['Input one', 'Input two']);
290
+ });
291
+
292
+ it('should prefer list over inline text for Inputs', () => {
293
+ const para = paragraph([strong('Inputs:'), text(' Inline text')]);
294
+ const inputList = list(['List item one', 'List item two']);
295
+ const result = parseTaskFields([para], [inputList]);
296
+ expect(result.inputs).toEqual(['List item one', 'List item two']);
297
+ });
298
+
299
+ it('should handle empty comma-separated values', () => {
300
+ const para = fieldParagraph({ Tags: 'a,, b' });
301
+ const result = parseTaskFields([para], []);
302
+ expect(result.tags).toEqual(['a', 'b']);
303
+ });
304
+
305
+ it('should parse multiple fields from single paragraph', () => {
306
+ const para = paragraph([
307
+ strong('Intent:'),
308
+ text(' Do something'),
309
+ strong('Confidence:'),
310
+ text(' high'),
311
+ ]);
312
+ const result = parseTaskFields([para], []);
313
+ expect(result.intent).toBe('Do something');
314
+ expect(result.confidence).toBe('high');
315
+ });
316
+
317
+ it('should parse fields across multiple paragraphs', () => {
318
+ const p1 = fieldParagraph({ Intent: 'Do something' });
319
+ const p2 = fieldParagraph({ Confidence: 'high' });
320
+ const p3 = fieldParagraph({ Tags: 'a, b' });
321
+ const result = parseTaskFields([p1, p2, p3], []);
322
+ expect(result.intent).toBe('Do something');
323
+ expect(result.confidence).toBe('high');
324
+ expect(result.tags).toEqual(['a', 'b']);
325
+ });
326
+
327
+ it('should handle inline code in field values', () => {
328
+ const para = paragraph([
329
+ strong('Validation:'),
330
+ text(' '),
331
+ inlineCode('pnpm -F anvil-core test'),
332
+ ]);
333
+ const result = parseTaskFields([para], []);
334
+ expect(result.validation).toBe('pnpm -F anvil-core test');
335
+ });
336
+
337
+ it('should handle break nodes in field values', () => {
338
+ const para = paragraph([strong('Intent:'), text(' Line one'), br(), text('Line two')]);
339
+ const result = parseTaskFields([para], []);
340
+ expect(result.intent).toBe('Line one Line two');
341
+ });
342
+
343
+ it('should collapse whitespace in field values', () => {
344
+ const para = paragraph([strong('Intent:'), text(' Multiple spaces here ')]);
345
+ const result = parseTaskFields([para], []);
346
+ expect(result.intent).toBe('Multiple spaces here');
347
+ });
348
+ });
349
+
350
+ describe('parseTask', () => {
351
+ it('should parse a complete task with all fields', () => {
352
+ const h = heading(3, 'AUTH-001: Create user database model');
353
+ const content = [
354
+ fieldParagraph({ Intent: 'Create the user database model for authentication' }),
355
+ paragraph([strong('Expected Outcome:'), text(' User model with all required fields')]),
356
+ paragraph([strong('Validation:'), text(' '), inlineCode('pnpm test')]),
357
+ fieldParagraph({ Confidence: 'high' }),
358
+ fieldParagraph({ Scopes: 'AUTH, DB' }),
359
+ fieldParagraph({ Tags: 'auth, database' }),
360
+ fieldParagraph({ Dependencies: 'INFRA-001' }),
361
+ fieldParagraph({ Files: 'src/model.ts, src/types.ts' }),
362
+ ];
363
+
364
+ const task = parseTask(h, content, 'plan.aps.md', 10);
365
+
366
+ expect(task.id).toBe('AUTH-001');
367
+ expect(task.title).toBe('Create user database model');
368
+ expect(task.intent).toBe('Create the user database model for authentication');
369
+ expect(task.expectedOutcome).toBe('User model with all required fields');
370
+ expect(task.validation).toBe('pnpm test');
371
+ expect(task.confidence).toBe('high');
372
+ expect(task.scopes).toEqual(['AUTH', 'DB']);
373
+ expect(task.tags).toEqual(['auth', 'database']);
374
+ expect(task.dependencies).toEqual(['INFRA-001']);
375
+ expect(task.files).toEqual(['src/model.ts', 'src/types.ts']);
376
+ expect(task.sourcePath).toBe('plan.aps.md');
377
+ expect(task.sourceLineNumber).toBe(10);
378
+ });
379
+
380
+ it('should parse a minimal task with only Intent', () => {
381
+ const h = heading(3, 'MIN-001: Minimal task');
382
+ const content = [fieldParagraph({ Intent: 'Just an intent' })];
383
+ const task = parseTask(h, content);
384
+
385
+ expect(task.id).toBe('MIN-001');
386
+ expect(task.title).toBe('Minimal task');
387
+ expect(task.intent).toBe('Just an intent');
388
+ expect(task.confidence).toBe('medium'); // default
389
+ expect(task.expectedOutcome).toBeUndefined();
390
+ expect(task.validation).toBeUndefined();
391
+ expect(task.scopes).toBeUndefined();
392
+ expect(task.tags).toBeUndefined();
393
+ expect(task.sourcePath).toBeUndefined();
394
+ expect(task.sourceLineNumber).toBeUndefined();
395
+ });
396
+
397
+ it('should throw ParseError when Intent is missing', () => {
398
+ const h = heading(3, 'TEST-001: No intent task');
399
+ const content = [fieldParagraph({ Confidence: 'high' })];
400
+
401
+ expect(() => parseTask(h, content)).toThrow(ParseError);
402
+ expect(() => parseTask(h, content)).toThrow(/missing required field: Intent/);
403
+ });
404
+
405
+ it('should throw ParseError when heading is invalid', () => {
406
+ const h = heading(3, 'bad heading');
407
+ const content = [fieldParagraph({ Intent: 'Intent' })];
408
+
409
+ expect(() => parseTask(h, content)).toThrow(ParseError);
410
+ });
411
+
412
+ it('should handle mixed paragraphs and lists', () => {
413
+ const h = heading(3, 'TEST-001: Task with inputs');
414
+ const content: Array<Paragraph | List> = [
415
+ fieldParagraph({ Intent: 'Task with list inputs' }),
416
+ paragraph([strong('Inputs:'), text('')]),
417
+ list(['Input one', 'Input two', 'Input three']),
418
+ ];
419
+
420
+ const task = parseTask(h, content);
421
+ expect(task.inputs).toEqual(['Input one', 'Input two', 'Input three']);
422
+ });
423
+
424
+ it('should set sourcePath and sourceLineNumber when provided', () => {
425
+ const h = heading(3, 'TEST-001: Tracked task');
426
+ const content = [fieldParagraph({ Intent: 'Tracked' })];
427
+ const task = parseTask(h, content, '/path/to/plan.md', 42);
428
+
429
+ expect(task.sourcePath).toBe('/path/to/plan.md');
430
+ expect(task.sourceLineNumber).toBe(42);
431
+ });
432
+
433
+ it('should handle task with Packages field', () => {
434
+ const h = heading(3, 'TEST-001: Multi-package task');
435
+ const content = [
436
+ fieldParagraph({ Intent: 'Affects multiple packages' }),
437
+ fieldParagraph({ Packages: '@app/core, @app/utils' }),
438
+ ];
439
+
440
+ const task = parseTask(h, content);
441
+ expect(task.packages).toEqual(['@app/core', '@app/utils']);
442
+ });
443
+
444
+ it('should handle task with Link field', () => {
445
+ const h = heading(3, 'TEST-001: Linked task');
446
+ const content = [
447
+ fieldParagraph({ Intent: 'Has external link' }),
448
+ fieldParagraph({ Link: 'https://jira.example.com/PROJ-123' }),
449
+ ];
450
+
451
+ const task = parseTask(h, content);
452
+ expect(task.link).toBe('https://jira.example.com/PROJ-123');
453
+ });
454
+
455
+ it('should handle task with Status field', () => {
456
+ const h = heading(3, 'TEST-001: Status task');
457
+ const content = [
458
+ fieldParagraph({ Intent: 'Has status' }),
459
+ fieldParagraph({ Status: 'completed' }),
460
+ ];
461
+
462
+ const task = parseTask(h, content);
463
+ expect(task.status).toBe('completed');
464
+ });
465
+
466
+ it('should handle task with Risks field', () => {
467
+ const h = heading(3, 'TEST-001: Risky task');
468
+ const content = [
469
+ fieldParagraph({ Intent: 'Has risks' }),
470
+ fieldParagraph({ Risks: 'data loss, performance degradation' }),
471
+ ];
472
+
473
+ const task = parseTask(h, content);
474
+ expect(task.risks).toEqual(['data loss', 'performance degradation']);
475
+ });
476
+ });