@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,298 @@
1
+ /**
2
+ * Index file parsing utilities
3
+ * Parses APS index files that organise multiple leaf specs
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, List, ListItem, Paragraph, Link } from 'mdast';
10
+ import { ParseError, type ModuleMetadata, type Priority } from '../types/index.js';
11
+
12
+ /**
13
+ * Parsed index file result
14
+ */
15
+ export interface ParsedIndex {
16
+ /** Plan title from H1 */
17
+ title: string;
18
+
19
+ /** Overview text (optional) */
20
+ overview?: string;
21
+
22
+ /** Module definitions */
23
+ modules: ModuleMetadata[];
24
+
25
+ /** Open questions (optional) */
26
+ openQuestions?: string[];
27
+
28
+ /** Decisions with dates (optional) */
29
+ decisions?: string[];
30
+
31
+ /** Source file path */
32
+ sourcePath?: string;
33
+ }
34
+
35
+ /**
36
+ * Parse an APS index file from Markdown content
37
+ *
38
+ * @param content - Markdown content
39
+ * @param sourcePath - Optional source file path for error reporting
40
+ * @returns Parsed index with modules
41
+ */
42
+ export async function parseIndex(content: string, sourcePath?: string): Promise<ParsedIndex> {
43
+ const processor = unified().use(remarkParse);
44
+ const ast = processor.parse(content) as Root;
45
+
46
+ const result: ParsedIndex = {
47
+ title: '',
48
+ modules: [],
49
+ sourcePath,
50
+ };
51
+
52
+ let currentSection: 'root' | 'overview' | 'modules' | 'questions' | 'decisions' = 'root';
53
+ let currentModuleId: string | null = null;
54
+ let currentModuleContent: List[] = [];
55
+
56
+ visit(ast, (node) => {
57
+ // H1: Plan title
58
+ if (node.type === 'heading' && (node as Heading).depth === 1) {
59
+ result.title = extractPlainText(node as Heading);
60
+ }
61
+
62
+ // H2: Section headers
63
+ if (node.type === 'heading' && (node as Heading).depth === 2) {
64
+ // Save previous module if any
65
+ if (currentModuleId && currentModuleContent.length > 0) {
66
+ const moduleMetadata = parseModuleMetadata(currentModuleId, currentModuleContent);
67
+ result.modules.push(moduleMetadata);
68
+ currentModuleId = null;
69
+ currentModuleContent = [];
70
+ }
71
+
72
+ const sectionTitle = extractPlainText(node as Heading).toLowerCase();
73
+
74
+ if (sectionTitle === 'overview') {
75
+ currentSection = 'overview';
76
+ } else if (sectionTitle === 'modules') {
77
+ currentSection = 'modules';
78
+ } else if (sectionTitle === 'open questions') {
79
+ currentSection = 'questions';
80
+ } else if (sectionTitle === 'decisions') {
81
+ currentSection = 'decisions';
82
+ } else {
83
+ currentSection = 'root';
84
+ }
85
+ }
86
+
87
+ // H3: Module headings (within Modules section)
88
+ if (node.type === 'heading' && (node as Heading).depth === 3 && currentSection === 'modules') {
89
+ // Save previous module if any
90
+ if (currentModuleId && currentModuleContent.length > 0) {
91
+ const moduleMetadata = parseModuleMetadata(currentModuleId, currentModuleContent);
92
+ result.modules.push(moduleMetadata);
93
+ }
94
+
95
+ currentModuleId = extractPlainText(node as Heading);
96
+ currentModuleContent = [];
97
+ }
98
+
99
+ // Collect lists for current module
100
+ if (node.type === 'list' && currentSection === 'modules' && currentModuleId) {
101
+ currentModuleContent.push(node as List);
102
+ }
103
+
104
+ // Collect overview paragraph
105
+ if (node.type === 'paragraph' && currentSection === 'overview') {
106
+ result.overview = extractPlainText(node as Paragraph);
107
+ }
108
+
109
+ // Collect open questions from list
110
+ if (node.type === 'list' && currentSection === 'questions') {
111
+ result.openQuestions = extractListItemsAsStrings(node as List);
112
+ }
113
+
114
+ // Collect decisions from list
115
+ if (node.type === 'list' && currentSection === 'decisions') {
116
+ result.decisions = extractListItemsAsStrings(node as List);
117
+ }
118
+ });
119
+
120
+ // Save last module if any
121
+ if (currentModuleId && currentModuleContent.length > 0) {
122
+ const moduleMetadata = parseModuleMetadata(currentModuleId, currentModuleContent);
123
+ result.modules.push(moduleMetadata);
124
+ }
125
+
126
+ // Validate
127
+ if (!result.title) {
128
+ throw new ParseError('Index file must have an H1 title', sourcePath);
129
+ }
130
+
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * Parse module metadata from list items
136
+ * Format: - **Field:** value
137
+ */
138
+ function parseModuleMetadata(moduleId: string, lists: List[]): ModuleMetadata {
139
+ const metadata: ModuleMetadata = {
140
+ id: moduleId,
141
+ };
142
+
143
+ for (const list of lists) {
144
+ for (const item of list.children) {
145
+ if (item.type !== 'listItem') continue;
146
+
147
+ const { key, value } = extractFieldFromListItem(item as ListItem);
148
+ if (!key) continue;
149
+
150
+ switch (key) {
151
+ case 'Path':
152
+ metadata.path = value;
153
+ break;
154
+ case 'Scope':
155
+ case 'ID':
156
+ // Support both 'Scope:' and 'ID:' per current APS spec
157
+ metadata.scope = value;
158
+ break;
159
+ case 'Owner':
160
+ metadata.owner = value;
161
+ break;
162
+ case 'Status': {
163
+ // Normalise status values - legacy values mapped to canonical equivalents
164
+ const statusMap: Record<string, ModuleMetadata['status']> = {
165
+ Draft: 'Proposed',
166
+ Proposed: 'Proposed',
167
+ Ready: 'Ready',
168
+ 'In Progress': 'In Progress',
169
+ Complete: 'Done',
170
+ Done: 'Done',
171
+ Blocked: 'Blocked',
172
+ };
173
+ const mapped = statusMap[value.trim()];
174
+ if (mapped) {
175
+ metadata.status = mapped;
176
+ }
177
+ break;
178
+ }
179
+ case 'Priority':
180
+ if (value === 'low' || value === 'medium' || value === 'high') {
181
+ metadata.priority = value as Priority;
182
+ }
183
+ break;
184
+ case 'Tags':
185
+ metadata.tags = parseCommaSeparated(value);
186
+ break;
187
+ case 'Dependencies':
188
+ if (value.toLowerCase() === '(none)' || value === '') {
189
+ metadata.dependencies = [];
190
+ } else {
191
+ metadata.dependencies = parseCommaSeparated(value);
192
+ }
193
+ break;
194
+ case 'Packages':
195
+ // Monorepo support: list of affected packages
196
+ if (value.toLowerCase() === '(none)' || value === '') {
197
+ metadata.packages = [];
198
+ } else {
199
+ metadata.packages = parseCommaSeparated(value);
200
+ }
201
+ break;
202
+ }
203
+ }
204
+ }
205
+
206
+ return metadata;
207
+ }
208
+
209
+ /**
210
+ * Extract field key and value from a list item
211
+ * Format: **Key:** value or **Key:** [link](url)
212
+ */
213
+ function extractFieldFromListItem(item: ListItem): { key: string; value: string } {
214
+ let key = '';
215
+ let value = '';
216
+
217
+ for (const child of item.children) {
218
+ if (child.type !== 'paragraph') continue;
219
+
220
+ const para = child as Paragraph;
221
+ let foundKey = false;
222
+
223
+ for (const node of para.children) {
224
+ if (node.type === 'strong') {
225
+ const strongText = extractPlainTextFromNode(node);
226
+ const match = strongText.match(/^(\w+):$/);
227
+ if (match) {
228
+ key = match[1];
229
+ foundKey = true;
230
+ }
231
+ } else if (foundKey) {
232
+ if (node.type === 'text') {
233
+ value += (node as { value: string }).value;
234
+ } else if (node.type === 'link') {
235
+ // For Path field, extract the URL from the link
236
+ const link = node as Link;
237
+ value += link.url;
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ return { key, value: value.trim() };
244
+ }
245
+
246
+ /**
247
+ * Extract plain text from a heading or paragraph
248
+ */
249
+ function extractPlainText(node: Heading | Paragraph): string {
250
+ let text = '';
251
+ visit(node, 'text', (textNode) => {
252
+ text += (textNode as { value: string }).value;
253
+ });
254
+ return text;
255
+ }
256
+
257
+ /**
258
+ * Extract plain text from any node
259
+ */
260
+ function extractPlainTextFromNode(node: unknown): string {
261
+ let text = '';
262
+ visit(node as Root, 'text', (textNode) => {
263
+ text += (textNode as { value: string }).value;
264
+ });
265
+ return text;
266
+ }
267
+
268
+ /**
269
+ * Extract list items as strings
270
+ */
271
+ function extractListItemsAsStrings(list: List): string[] {
272
+ const items: string[] = [];
273
+
274
+ for (const item of list.children) {
275
+ if (item.type !== 'listItem') continue;
276
+
277
+ let text = '';
278
+ visit(item, 'text', (textNode) => {
279
+ text += (textNode as { value: string }).value;
280
+ });
281
+
282
+ if (text.trim()) {
283
+ items.push(text.trim());
284
+ }
285
+ }
286
+
287
+ return items;
288
+ }
289
+
290
+ /**
291
+ * Parse comma-separated list
292
+ */
293
+ function parseCommaSeparated(value: string): string[] {
294
+ return value
295
+ .split(',')
296
+ .map((item) => item.trim())
297
+ .filter((item) => item.length > 0);
298
+ }