@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,325 @@
1
+ /**
2
+ * Task parsing utilities
3
+ * Extracts task information from Markdown AST nodes
4
+ */
5
+
6
+ import type { Heading, Paragraph, Strong, List, PhrasingContent } from 'mdast';
7
+ import { ParseError, type Task, type Confidence, type TaskStatus } from '../types/index.js';
8
+
9
+ /**
10
+ * Extract task ID and title from H3 heading text
11
+ * Format: "SCOPE-NUMBER: Task title"
12
+ * - Scope: 1-10 uppercase alphanumeric characters
13
+ * - Number: 3-digit zero-padded (001-999)
14
+ */
15
+ export function parseTaskHeading(heading: Heading): { id: string; title: string } {
16
+ if (heading.depth !== 3) {
17
+ throw new ParseError(
18
+ 'Task headings must be H3 (###)',
19
+ undefined,
20
+ undefined,
21
+ 'parseTaskHeading'
22
+ );
23
+ }
24
+
25
+ const text = extractPlainText(heading);
26
+ // Extract ID and title - ID must match TASK_ID_REGEX format
27
+ const match = text.match(/^([A-Z0-9]{1,10}-\d{3}):\s*(.+)$/);
28
+
29
+ if (!match) {
30
+ throw new ParseError(
31
+ `Invalid task heading format. Expected "SCOPE-NNN: Title" (e.g., AUTH-001), got: "${text}"`,
32
+ undefined,
33
+ undefined,
34
+ 'parseTaskHeading'
35
+ );
36
+ }
37
+
38
+ return {
39
+ id: match[1],
40
+ title: match[2].trim(),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Extract plain text from AST node (handles inline formatting)
46
+ */
47
+ function extractPlainText(node: Heading | Paragraph | PhrasingContent): string {
48
+ if ('value' in node && typeof node.value === 'string') {
49
+ return node.value;
50
+ }
51
+
52
+ if ('children' in node && Array.isArray(node.children)) {
53
+ return node.children.map((child) => extractPlainText(child as PhrasingContent)).join('');
54
+ }
55
+
56
+ return '';
57
+ }
58
+
59
+ /**
60
+ * Parse task fields from paragraph and list nodes
61
+ * Fields are in format: **FieldName:** value
62
+ */
63
+ export function parseTaskFields(paragraphs: Paragraph[], lists: List[]): Partial<Task> {
64
+ const fields: Partial<Task> = {};
65
+ let lastField: string | null = null;
66
+ let inlineInputs: string | null = null;
67
+
68
+ // First pass: extract all field key-value pairs from paragraphs
69
+ for (const para of paragraphs) {
70
+ const fieldMatches = extractFieldsFromParagraph(para);
71
+
72
+ for (const [key, value] of Object.entries(fieldMatches)) {
73
+ // Handle Inputs specially - may be inline text or followed by a list
74
+ if (key === 'Inputs') {
75
+ if (value.trim() === '') {
76
+ // Empty value - expect a list to follow
77
+ lastField = key;
78
+ } else {
79
+ // Inline text value - store for later (list takes precedence if present)
80
+ inlineInputs = value.trim();
81
+ lastField = key;
82
+ }
83
+ continue;
84
+ }
85
+
86
+ assignField(fields, key, value);
87
+ lastField = key;
88
+ }
89
+ }
90
+
91
+ // Second pass: handle Inputs field
92
+ // Lists take precedence over inline text
93
+ if (lastField === 'Inputs' && lists.length > 0) {
94
+ fields.inputs = extractListItems(lists[0]);
95
+ } else if (inlineInputs !== null) {
96
+ // Use inline text as a single-item array
97
+ fields.inputs = [inlineInputs];
98
+ }
99
+
100
+ return fields;
101
+ }
102
+
103
+ /**
104
+ * Extract field key-value pairs from a paragraph containing bold markers
105
+ */
106
+ function extractFieldsFromParagraph(para: Paragraph): Record<string, string> {
107
+ const fields: Record<string, string> = {};
108
+ let currentKey = '';
109
+ let currentValue = '';
110
+ let inField = false;
111
+
112
+ for (const child of para.children) {
113
+ if (child.type === 'strong') {
114
+ // Check if this strong node contains a field name
115
+ const strongText = extractPlainText(child as Strong);
116
+ const fieldMatch = strongText.match(/^([\w-]+(?:\s+[\w-]+)*):$/);
117
+
118
+ if (fieldMatch) {
119
+ // Save previous field if exists (even if value is empty)
120
+ if (currentKey) {
121
+ fields[currentKey] = currentValue.trim().replace(/\s+/g, ' ');
122
+ }
123
+
124
+ currentKey = fieldMatch[1];
125
+ currentValue = '';
126
+ inField = true;
127
+ }
128
+ } else if (inField) {
129
+ // Extract text from any phrasing content node (text, inlineCode, etc.)
130
+ // This handles validation commands in backticks and other inline formatting
131
+ if (child.type === 'break') {
132
+ // Convert breaks to spaces to handle multi-line values
133
+ currentValue += ' ';
134
+ } else {
135
+ currentValue += extractPlainText(child as PhrasingContent);
136
+ }
137
+ }
138
+ }
139
+
140
+ // Save last field (even if value is empty)
141
+ if (currentKey) {
142
+ fields[currentKey] = currentValue.trim().replace(/\s+/g, ' ');
143
+ }
144
+
145
+ return fields;
146
+ }
147
+
148
+ /**
149
+ * Assign a parsed field value to the appropriate Task property
150
+ */
151
+ function assignField(task: Partial<Task>, key: string, value: string): void {
152
+ const normalizedKey = key.replace(/\s+/g, '');
153
+
154
+ switch (normalizedKey) {
155
+ case 'Intent':
156
+ task.intent = value;
157
+ break;
158
+
159
+ case 'ExpectedOutcome':
160
+ task.expectedOutcome = value;
161
+ break;
162
+
163
+ case 'Validation':
164
+ case 'Test':
165
+ // Support both "Validation:" and "Test:" field names per APS spec
166
+ task.validation = value;
167
+ break;
168
+
169
+ case 'Confidence':
170
+ task.confidence = parseConfidence(value);
171
+ break;
172
+
173
+ case 'Scopes':
174
+ task.scopes = parseCommaSeparated(value);
175
+ break;
176
+
177
+ case 'NonScope':
178
+ case 'Non-scope':
179
+ task.nonScope = parseCommaSeparated(value);
180
+ break;
181
+
182
+ case 'Files':
183
+ task.files = parseCommaSeparated(value);
184
+ break;
185
+
186
+ case 'Tags':
187
+ task.tags = parseCommaSeparated(value);
188
+ break;
189
+
190
+ case 'Dependencies':
191
+ task.dependencies = parseCommaSeparated(value);
192
+ break;
193
+
194
+ case 'Risks':
195
+ task.risks = parseCommaSeparated(value);
196
+ break;
197
+
198
+ case 'Packages': {
199
+ // Monorepo support: list of affected packages
200
+ const trimmed = value.trim();
201
+ if (!trimmed || trimmed.toLowerCase() === '(none)') {
202
+ task.packages = [];
203
+ } else {
204
+ task.packages = parseCommaSeparated(value);
205
+ }
206
+ break;
207
+ }
208
+
209
+ case 'Link':
210
+ task.link = value;
211
+ break;
212
+
213
+ case 'Status':
214
+ task.status = parseStatus(value);
215
+ break;
216
+
217
+ // 'Inputs' is handled separately as a list
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Parse confidence value from string
223
+ */
224
+ function parseConfidence(value: string): Confidence {
225
+ const normalized = value.toLowerCase().trim();
226
+ if (normalized === 'low' || normalized === 'medium' || normalized === 'high') {
227
+ return normalized;
228
+ }
229
+ return 'medium'; // default
230
+ }
231
+
232
+ /**
233
+ * Parse task status from string
234
+ */
235
+ function parseStatus(value: string): TaskStatus {
236
+ const normalized = value.toLowerCase().trim();
237
+ if (
238
+ normalized === 'open' ||
239
+ normalized === 'locked' ||
240
+ normalized === 'completed' ||
241
+ normalized === 'cancelled'
242
+ ) {
243
+ return normalized;
244
+ }
245
+ return 'open'; // default
246
+ }
247
+
248
+ /**
249
+ * Parse comma-separated list into array
250
+ */
251
+ function parseCommaSeparated(value: string): string[] {
252
+ return value
253
+ .split(',')
254
+ .map((item) => item.trim())
255
+ .filter((item) => item.length > 0);
256
+ }
257
+
258
+ /**
259
+ * Extract list items as strings
260
+ */
261
+ function extractListItems(list: List): string[] {
262
+ const items: string[] = [];
263
+
264
+ for (const item of list.children) {
265
+ if (item.type === 'listItem' && item.children.length > 0) {
266
+ const firstChild = item.children[0];
267
+ if (firstChild && firstChild.type === 'paragraph') {
268
+ items.push(extractPlainText(firstChild));
269
+ }
270
+ }
271
+ }
272
+
273
+ return items;
274
+ }
275
+
276
+ /**
277
+ * Parse a complete task from AST nodes
278
+ * @param heading - H3 heading node with task ID and title
279
+ * @param content - Array of paragraph and list nodes containing task fields
280
+ * @param sourcePath - Optional source file path
281
+ * @param lineNumber - Optional line number where task starts
282
+ */
283
+ export function parseTask(
284
+ heading: Heading,
285
+ content: Array<Paragraph | List>,
286
+ sourcePath?: string,
287
+ lineNumber?: number
288
+ ): Task {
289
+ const { id, title } = parseTaskHeading(heading);
290
+
291
+ const paragraphs = content.filter((node) => node.type === 'paragraph') as Paragraph[];
292
+ const lists = content.filter((node) => node.type === 'list') as List[];
293
+
294
+ const fields = parseTaskFields(paragraphs, lists);
295
+
296
+ if (!fields.intent) {
297
+ throw new ParseError(
298
+ `Task ${id} is missing required field: Intent`,
299
+ sourcePath,
300
+ lineNumber,
301
+ 'parseTask'
302
+ );
303
+ }
304
+
305
+ return {
306
+ id,
307
+ title,
308
+ intent: fields.intent,
309
+ expectedOutcome: fields.expectedOutcome,
310
+ validation: fields.validation,
311
+ confidence: fields.confidence ?? 'medium',
312
+ scopes: fields.scopes,
313
+ nonScope: fields.nonScope,
314
+ files: fields.files,
315
+ tags: fields.tags,
316
+ dependencies: fields.dependencies,
317
+ inputs: fields.inputs,
318
+ risks: fields.risks,
319
+ packages: fields.packages,
320
+ link: fields.link,
321
+ status: fields.status,
322
+ sourcePath,
323
+ sourceLineNumber: lineNumber,
324
+ };
325
+ }
@@ -0,0 +1,9 @@
1
+ # Invalid Plan
2
+
3
+ **Scope:** INV
4
+
5
+ ## Tasks
6
+
7
+ ### INV-001: Task without intent
8
+
9
+ **Confidence:** high
@@ -0,0 +1,20 @@
1
+ # Test Plan
2
+
3
+ **Scope:** TEST **Owner:** @tester
4
+
5
+ > A simple plan for testing task locking.
6
+
7
+ ## Tasks
8
+
9
+ ### TEST-001: First task
10
+
11
+ **Intent:** Complete the first task **Confidence:** high **Tags:** core
12
+
13
+ ### TEST-002: Second task
14
+
15
+ **Intent:** Complete the second task **Confidence:** medium **Dependencies:**
16
+ TEST-001
17
+
18
+ ### TEST-003: Third task
19
+
20
+ **Intent:** Complete the third task with low confidence **Confidence:** low