@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,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,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
|