@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,776 @@
1
+ /**
2
+ * Validator module - Validation rules for APS planning documents
3
+ *
4
+ * Provides validation for:
5
+ * - Required sections (Index: ## Modules, Leaf: ## Tasks)
6
+ * - Task format (ID, Intent required)
7
+ * - Duplicate task IDs across plan graph
8
+ * - Broken module links
9
+ * - Scope mismatches (warning)
10
+ * - Missing Confidence (warning)
11
+ * - Missing Expected Outcome (warning)
12
+ * - Missing Validation/Test (warning)
13
+ * - Orphan leaf specs (warning)
14
+ * - Circular module dependencies
15
+ */
16
+
17
+ import { promises as fs, accessSync } from 'node:fs';
18
+ import { dirname, isAbsolute, resolve } from 'node:path';
19
+ import { unified } from 'unified';
20
+ import remarkParse from 'remark-parse';
21
+ import { visit } from 'unist-util-visit';
22
+ import type { Root, Heading, Paragraph, Link } from 'mdast';
23
+ import { TASK_ID_REGEX } from '../types/index.js';
24
+ import { loadPlan, detectCycles, resolvePath, type LoadedPlan } from '../loader/index.js';
25
+
26
+ /**
27
+ * Validation issue severity
28
+ */
29
+ export type ValidationSeverity = 'error' | 'warning';
30
+
31
+ /**
32
+ * A single validation issue
33
+ */
34
+ export interface ValidationIssue {
35
+ /** Severity level */
36
+ severity: ValidationSeverity;
37
+
38
+ /** Human-readable message */
39
+ message: string;
40
+
41
+ /** Rule that triggered this issue */
42
+ rule: string;
43
+
44
+ /** File path where the issue was found */
45
+ path?: string;
46
+
47
+ /** Line number in the file (1-based) */
48
+ lineNumber?: number;
49
+
50
+ /** Additional context */
51
+ context?: string;
52
+ }
53
+
54
+ /**
55
+ * Result of validating a planning document
56
+ */
57
+ export interface ValidationResult {
58
+ /** Whether the document is valid (no errors, warnings allowed) */
59
+ valid: boolean;
60
+
61
+ /** List of all issues found */
62
+ issues: ValidationIssue[];
63
+
64
+ /** Just the errors */
65
+ errors: ValidationIssue[];
66
+
67
+ /** Just the warnings */
68
+ warnings: ValidationIssue[];
69
+ }
70
+
71
+ /**
72
+ * Options for validation
73
+ */
74
+ export interface ValidateOptions {
75
+ /** Base directory for resolving relative paths */
76
+ baseDir?: string;
77
+
78
+ /** Whether to recursively validate linked modules (default: true) */
79
+ recursive?: boolean;
80
+
81
+ /** Rules to skip (by rule name) */
82
+ skipRules?: string[];
83
+ }
84
+
85
+ /**
86
+ * Validate an APS planning document
87
+ *
88
+ * @param filePath - Path to the planning document (index or leaf spec)
89
+ * @param options - Validation options
90
+ * @returns Validation result with errors and warnings
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * const result = await validatePlanningDoc('docs/planning/APS.md');
95
+ * if (!result.valid) {
96
+ * for (const error of result.errors) {
97
+ * console.error(`${error.path}:${error.lineNumber}: ${error.message}`);
98
+ * }
99
+ * }
100
+ * ```
101
+ */
102
+ export async function validatePlanningDoc(
103
+ filePath: string,
104
+ options: ValidateOptions = {}
105
+ ): Promise<ValidationResult> {
106
+ const absolutePath = isAbsolute(filePath) ? filePath : resolve(filePath);
107
+ const baseDir = options.baseDir ?? dirname(absolutePath);
108
+ const recursive = options.recursive ?? true;
109
+ const skipRules = new Set(options.skipRules ?? []);
110
+
111
+ const issues: ValidationIssue[] = [];
112
+
113
+ // Read the file content
114
+ let content: string;
115
+ try {
116
+ content = await fs.readFile(absolutePath, 'utf-8');
117
+ } catch (error) {
118
+ issues.push({
119
+ severity: 'error',
120
+ message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
121
+ rule: 'file-readable',
122
+ path: absolutePath,
123
+ });
124
+ return createResult(issues);
125
+ }
126
+
127
+ // Determine document type and validate structure
128
+ const isIndex = detectIndexFile(content);
129
+
130
+ if (isIndex) {
131
+ // Validate index file structure
132
+ if (!skipRules.has('required-sections')) {
133
+ await validateIndexStructure(content, absolutePath, issues);
134
+ }
135
+
136
+ // Validate module links
137
+ if (!skipRules.has('broken-links') && recursive) {
138
+ await validateModuleLinks(content, absolutePath, baseDir, issues);
139
+ }
140
+ } else {
141
+ // Validate leaf spec structure
142
+ if (!skipRules.has('required-sections')) {
143
+ validateLeafStructure(content, absolutePath, issues);
144
+ }
145
+
146
+ // Validate tasks
147
+ if (!skipRules.has('task-format')) {
148
+ validateTaskFormat(content, absolutePath, issues, skipRules);
149
+ }
150
+ }
151
+
152
+ // Load the full plan for cross-document validation
153
+ if (recursive) {
154
+ try {
155
+ const plan = await loadPlan(absolutePath, { baseDir, recursive: true });
156
+
157
+ // Check for duplicate task IDs
158
+ if (!skipRules.has('duplicate-ids')) {
159
+ validateDuplicateTaskIds(plan, issues);
160
+ }
161
+
162
+ // Check for circular dependencies
163
+ if (!skipRules.has('circular-dependencies')) {
164
+ validateCircularDependencies(plan, issues);
165
+ }
166
+
167
+ // Check scope mismatches
168
+ if (!skipRules.has('scope-mismatch')) {
169
+ validateScopeMismatches(plan, issues);
170
+ }
171
+
172
+ // Check for orphan modules (only if index file)
173
+ if (isIndex && !skipRules.has('orphan-modules')) {
174
+ await validateOrphanModules(absolutePath, baseDir, plan, issues);
175
+ }
176
+ } catch (error) {
177
+ // If we can't load the plan, the earlier validation errors should explain why
178
+ if (issues.length === 0) {
179
+ issues.push({
180
+ severity: 'error',
181
+ message: `Failed to load plan: ${error instanceof Error ? error.message : String(error)}`,
182
+ rule: 'plan-loadable',
183
+ path: absolutePath,
184
+ });
185
+ }
186
+ }
187
+ }
188
+
189
+ return createResult(issues);
190
+ }
191
+
192
+ /**
193
+ * Detect if content is an index file (has ## Modules section)
194
+ */
195
+ function detectIndexFile(content: string): boolean {
196
+ const processor = unified().use(remarkParse);
197
+ const ast = processor.parse(content) as Root;
198
+
199
+ let hasModulesSection = false;
200
+
201
+ visit(ast, 'heading', (node: Heading) => {
202
+ if (node.depth === 2) {
203
+ let text = '';
204
+ visit(node, 'text', (textNode: { value: string }) => {
205
+ text += textNode.value;
206
+ });
207
+
208
+ const normalizedText = text.trim().toLowerCase();
209
+ if (normalizedText === 'modules' || normalizedText.startsWith('modules')) {
210
+ hasModulesSection = true;
211
+ }
212
+ }
213
+ });
214
+
215
+ return hasModulesSection;
216
+ }
217
+
218
+ /**
219
+ * Validate index file structure
220
+ */
221
+ async function validateIndexStructure(
222
+ content: string,
223
+ filePath: string,
224
+ issues: ValidationIssue[]
225
+ ): Promise<void> {
226
+ const processor = unified().use(remarkParse);
227
+ const ast = processor.parse(content) as Root;
228
+
229
+ let hasH1 = false;
230
+ let hasModulesSection = false;
231
+ let modulesLineNumber = 0;
232
+ let hasModuleEntries = false;
233
+
234
+ visit(ast, 'heading', (node: Heading) => {
235
+ if (node.depth === 1) {
236
+ hasH1 = true;
237
+ }
238
+ if (node.depth === 2) {
239
+ let text = '';
240
+ visit(node, 'text', (textNode: { value: string }) => {
241
+ text += textNode.value;
242
+ });
243
+
244
+ const normalizedText = text.trim().toLowerCase();
245
+ if (normalizedText === 'modules' || normalizedText.startsWith('modules')) {
246
+ hasModulesSection = true;
247
+ modulesLineNumber = node.position?.start.line ?? 0;
248
+ }
249
+ }
250
+ if (node.depth === 3 && hasModulesSection) {
251
+ hasModuleEntries = true;
252
+ }
253
+ });
254
+
255
+ if (!hasH1) {
256
+ issues.push({
257
+ severity: 'error',
258
+ message: 'Index file must have an H1 title',
259
+ rule: 'required-sections',
260
+ path: filePath,
261
+ lineNumber: 1,
262
+ });
263
+ }
264
+
265
+ if (!hasModulesSection) {
266
+ issues.push({
267
+ severity: 'error',
268
+ message: 'Index file must have a "## Modules" section',
269
+ rule: 'required-sections',
270
+ path: filePath,
271
+ });
272
+ } else if (!hasModuleEntries) {
273
+ issues.push({
274
+ severity: 'warning',
275
+ message: '"## Modules" section has no module entries (H3 headings)',
276
+ rule: 'required-sections',
277
+ path: filePath,
278
+ lineNumber: modulesLineNumber,
279
+ });
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Validate leaf spec structure
285
+ */
286
+ function validateLeafStructure(content: string, filePath: string, issues: ValidationIssue[]): void {
287
+ const processor = unified().use(remarkParse);
288
+ const ast = processor.parse(content) as Root;
289
+
290
+ let hasH1 = false;
291
+ let hasTasksSection = false;
292
+ let tasksLineNumber = 0;
293
+ let hasTaskEntries = false;
294
+
295
+ visit(ast, 'heading', (node: Heading) => {
296
+ if (node.depth === 1) {
297
+ hasH1 = true;
298
+ }
299
+ if (node.depth === 2) {
300
+ let text = '';
301
+ visit(node, 'text', (textNode: { value: string }) => {
302
+ text += textNode.value;
303
+ });
304
+
305
+ if (text.trim().toLowerCase() === 'tasks') {
306
+ hasTasksSection = true;
307
+ tasksLineNumber = node.position?.start.line ?? 0;
308
+ }
309
+ }
310
+ if (node.depth === 3 && hasTasksSection) {
311
+ hasTaskEntries = true;
312
+ }
313
+ });
314
+
315
+ if (!hasH1) {
316
+ issues.push({
317
+ severity: 'error',
318
+ message: 'Leaf spec must have an H1 title',
319
+ rule: 'required-sections',
320
+ path: filePath,
321
+ lineNumber: 1,
322
+ });
323
+ }
324
+
325
+ if (!hasTasksSection) {
326
+ issues.push({
327
+ severity: 'error',
328
+ message: 'Leaf spec must have a "## Tasks" section',
329
+ rule: 'required-sections',
330
+ path: filePath,
331
+ });
332
+ } else if (!hasTaskEntries) {
333
+ issues.push({
334
+ severity: 'warning',
335
+ message: '"## Tasks" section has no task entries (H3 headings)',
336
+ rule: 'required-sections',
337
+ path: filePath,
338
+ lineNumber: tasksLineNumber,
339
+ });
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Validate module links in an index file
345
+ */
346
+ async function validateModuleLinks(
347
+ content: string,
348
+ filePath: string,
349
+ baseDir: string,
350
+ issues: ValidationIssue[]
351
+ ): Promise<void> {
352
+ const processor = unified().use(remarkParse);
353
+ const ast = processor.parse(content) as Root;
354
+
355
+ let inModulesSection = false;
356
+ let currentModuleId: string | null = null;
357
+
358
+ visit(ast, (node) => {
359
+ if (node.type === 'heading') {
360
+ const heading = node as Heading;
361
+ if (heading.depth === 2) {
362
+ let text = '';
363
+ visit(heading, 'text', (textNode: { value: string }) => {
364
+ text += textNode.value;
365
+ });
366
+ const normalizedText = text.trim().toLowerCase();
367
+ inModulesSection = normalizedText === 'modules' || normalizedText.startsWith('modules');
368
+ }
369
+ if (heading.depth === 3 && inModulesSection) {
370
+ let text = '';
371
+ visit(heading, 'text', (textNode: { value: string }) => {
372
+ text += textNode.value;
373
+ });
374
+ currentModuleId = text.trim();
375
+ }
376
+ }
377
+
378
+ // Check for Path links in list items
379
+ if (node.type === 'paragraph' && inModulesSection && currentModuleId) {
380
+ const para = node as Paragraph;
381
+ let hasPathField = false;
382
+ let linkUrl: string | null = null;
383
+ const linkLine = para.position?.start.line ?? 0;
384
+
385
+ for (const child of para.children) {
386
+ if (child.type === 'strong') {
387
+ let strongText = '';
388
+ visit(child, 'text', (textNode: { value: string }) => {
389
+ strongText += textNode.value;
390
+ });
391
+ if (strongText === 'Path:') {
392
+ hasPathField = true;
393
+ }
394
+ }
395
+ if (child.type === 'link' && hasPathField) {
396
+ linkUrl = (child as Link).url;
397
+ }
398
+ }
399
+
400
+ if (hasPathField && linkUrl) {
401
+ // Validate the link exists
402
+ const resolvedPath = resolvePath(linkUrl, baseDir);
403
+ validateFileExists(resolvedPath, filePath, linkLine, currentModuleId, issues);
404
+ }
405
+ }
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Validate that a file exists (async check queued for later)
411
+ */
412
+ function validateFileExists(
413
+ targetPath: string,
414
+ sourcePath: string,
415
+ lineNumber: number,
416
+ moduleId: string,
417
+ issues: ValidationIssue[]
418
+ ): void {
419
+ // Use sync check for simplicity (file system is fast for existence checks)
420
+ try {
421
+ accessSync(targetPath);
422
+ } catch {
423
+ issues.push({
424
+ severity: 'error',
425
+ message: `Broken link: module "${moduleId}" links to non-existent file "${targetPath}"`,
426
+ rule: 'broken-links',
427
+ path: sourcePath,
428
+ lineNumber,
429
+ context: `Module: ${moduleId}`,
430
+ });
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Validate task format in a leaf spec
436
+ */
437
+ function validateTaskFormat(
438
+ content: string,
439
+ filePath: string,
440
+ issues: ValidationIssue[],
441
+ skipRules: Set<string>
442
+ ): void {
443
+ const processor = unified().use(remarkParse);
444
+ const ast = processor.parse(content) as Root;
445
+
446
+ let inTasksSection = false;
447
+ let currentTaskHeading: { id: string; title: string; line: number } | null = null;
448
+ let currentTaskContent: string[] = [];
449
+
450
+ visit(ast, (node) => {
451
+ if (node.type === 'heading') {
452
+ const heading = node as Heading;
453
+
454
+ if (heading.depth === 2) {
455
+ // Check for section change
456
+ if (currentTaskHeading) {
457
+ validateTaskContent(currentTaskHeading, currentTaskContent, filePath, issues, skipRules);
458
+ currentTaskHeading = null;
459
+ currentTaskContent = [];
460
+ }
461
+
462
+ let text = '';
463
+ visit(heading, 'text', (textNode: { value: string }) => {
464
+ text += textNode.value;
465
+ });
466
+ inTasksSection = text.trim().toLowerCase() === 'tasks';
467
+ }
468
+
469
+ if (heading.depth === 3 && inTasksSection) {
470
+ // Save previous task
471
+ if (currentTaskHeading) {
472
+ validateTaskContent(currentTaskHeading, currentTaskContent, filePath, issues, skipRules);
473
+ }
474
+
475
+ // Parse task heading
476
+ let text = '';
477
+ visit(heading, 'text', (textNode: { value: string }) => {
478
+ text += textNode.value;
479
+ });
480
+
481
+ const lineNumber = heading.position?.start.line ?? 0;
482
+ const match = text.match(/^([A-Z0-9]+-\d+):\s*(.*)$/);
483
+
484
+ if (!match) {
485
+ issues.push({
486
+ severity: 'error',
487
+ message: `Invalid task heading format: "${text}". Expected "ID: Title" (e.g., "AUTH-001: Implement login")`,
488
+ rule: 'task-format',
489
+ path: filePath,
490
+ lineNumber,
491
+ });
492
+ currentTaskHeading = null;
493
+ } else {
494
+ const [, id, title] = match;
495
+
496
+ // Validate task ID format
497
+ if (!TASK_ID_REGEX.test(id)) {
498
+ issues.push({
499
+ severity: 'error',
500
+ message: `Invalid task ID format: "${id}". Expected 1-10 alphanumeric scope + hyphen + 3-digit number (e.g., AUTH-001)`,
501
+ rule: 'task-format',
502
+ path: filePath,
503
+ lineNumber,
504
+ });
505
+ }
506
+
507
+ currentTaskHeading = { id, title, line: lineNumber };
508
+ currentTaskContent = [];
509
+ }
510
+ }
511
+ }
512
+
513
+ // Collect task content
514
+ if (currentTaskHeading && node.type === 'paragraph') {
515
+ let text = '';
516
+ visit(node, 'text', (textNode: { value: string }) => {
517
+ text += textNode.value;
518
+ });
519
+ visit(node, 'strong', (strongNode) => {
520
+ visit(strongNode, 'text', (textNode: { value: string }) => {
521
+ text += textNode.value;
522
+ });
523
+ });
524
+ currentTaskContent.push(text);
525
+ }
526
+ });
527
+
528
+ // Validate last task
529
+ if (currentTaskHeading) {
530
+ validateTaskContent(currentTaskHeading, currentTaskContent, filePath, issues, skipRules);
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Validate task content (Intent required, Confidence/Validation/ExpectedOutcome warnings)
536
+ */
537
+ function validateTaskContent(
538
+ task: { id: string; title: string; line: number },
539
+ content: string[],
540
+ filePath: string,
541
+ issues: ValidationIssue[],
542
+ skipRules: Set<string>
543
+ ): void {
544
+ const fullContent = content.join(' ');
545
+
546
+ // Check for Intent (required)
547
+ if (!skipRules.has('task-intent') && !fullContent.includes('Intent:')) {
548
+ issues.push({
549
+ severity: 'error',
550
+ message: `Task "${task.id}" is missing required **Intent:** field`,
551
+ rule: 'task-intent',
552
+ path: filePath,
553
+ lineNumber: task.line,
554
+ });
555
+ }
556
+
557
+ // Check for Expected Outcome (warning per APS spec)
558
+ if (
559
+ !skipRules.has('missing-expected-outcome') &&
560
+ !fullContent.includes('Expected Outcome:') &&
561
+ !fullContent.includes('ExpectedOutcome:')
562
+ ) {
563
+ issues.push({
564
+ severity: 'warning',
565
+ message: `Task "${task.id}" is missing **Expected Outcome:** field (recommended for testability)`,
566
+ rule: 'missing-expected-outcome',
567
+ path: filePath,
568
+ lineNumber: task.line,
569
+ });
570
+ }
571
+
572
+ // Check for Validation/Test (warning per APS spec)
573
+ if (
574
+ !skipRules.has('missing-validation') &&
575
+ !fullContent.includes('Validation:') &&
576
+ !fullContent.includes('Test:')
577
+ ) {
578
+ issues.push({
579
+ severity: 'warning',
580
+ message: `Task "${task.id}" is missing **Validation:** or **Test:** field (recommended for verification)`,
581
+ rule: 'missing-validation',
582
+ path: filePath,
583
+ lineNumber: task.line,
584
+ });
585
+ }
586
+
587
+ // Check for Confidence (warning)
588
+ if (!skipRules.has('missing-confidence') && !fullContent.includes('Confidence:')) {
589
+ issues.push({
590
+ severity: 'warning',
591
+ message: `Task "${task.id}" is missing **Confidence:** field (defaults to "medium")`,
592
+ rule: 'missing-confidence',
593
+ path: filePath,
594
+ lineNumber: task.line,
595
+ });
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Validate duplicate task IDs across the plan
601
+ */
602
+ function validateDuplicateTaskIds(plan: LoadedPlan, issues: ValidationIssue[]): void {
603
+ const taskLocations = new Map<string, Array<{ path: string; line?: number }>>();
604
+
605
+ for (const task of plan.allTasks) {
606
+ const locations = taskLocations.get(task.id) ?? [];
607
+ locations.push({
608
+ path: task.sourcePath ?? 'unknown',
609
+ line: task.sourceLineNumber,
610
+ });
611
+ taskLocations.set(task.id, locations);
612
+ }
613
+
614
+ for (const [taskId, locations] of taskLocations) {
615
+ if (locations.length > 1) {
616
+ const locationStrings = locations
617
+ .map((loc) => `${loc.path}${loc.line ? `:${loc.line}` : ''}`)
618
+ .join(', ');
619
+
620
+ issues.push({
621
+ severity: 'error',
622
+ message: `Duplicate task ID "${taskId}" found in: ${locationStrings}`,
623
+ rule: 'duplicate-ids',
624
+ path: locations[0].path,
625
+ lineNumber: locations[0].line,
626
+ context: `Also found at: ${locations
627
+ .slice(1)
628
+ .map((l) => `${l.path}:${l.line}`)
629
+ .join(', ')}`,
630
+ });
631
+ }
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Validate circular module dependencies
637
+ */
638
+ function validateCircularDependencies(plan: LoadedPlan, issues: ValidationIssue[]): void {
639
+ const cycles = detectCycles(plan);
640
+
641
+ for (const cycle of cycles) {
642
+ const cycleStr = cycle.join(' -> ');
643
+ issues.push({
644
+ severity: 'error',
645
+ message: `Circular dependency detected: ${cycleStr}`,
646
+ rule: 'circular-dependencies',
647
+ path: plan.rootPath,
648
+ context: `Cycle: ${cycleStr}`,
649
+ });
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Validate scope mismatches (task ID prefix vs module scope)
655
+ */
656
+ function validateScopeMismatches(plan: LoadedPlan, issues: ValidationIssue[]): void {
657
+ for (const module of plan.modules.values()) {
658
+ const moduleScope = module.metadata.scope?.toUpperCase();
659
+
660
+ for (const task of module.tasks) {
661
+ const taskScope = task.id.split('-')[0];
662
+
663
+ if (moduleScope && taskScope !== moduleScope) {
664
+ issues.push({
665
+ severity: 'warning',
666
+ message: `Task "${task.id}" has scope prefix "${taskScope}" but belongs to module with scope "${moduleScope}"`,
667
+ rule: 'scope-mismatch',
668
+ path: task.sourcePath,
669
+ lineNumber: task.sourceLineNumber,
670
+ context: `Module scope: ${moduleScope}`,
671
+ });
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Validate for orphan modules (leaf specs in directory not linked from index)
679
+ */
680
+ async function validateOrphanModules(
681
+ indexPath: string,
682
+ baseDir: string,
683
+ plan: LoadedPlan,
684
+ issues: ValidationIssue[]
685
+ ): Promise<void> {
686
+ // Get all linked module paths
687
+ const linkedPaths = new Set<string>();
688
+ for (const module of plan.modules.values()) {
689
+ linkedPaths.add(module.resolvedPath);
690
+ }
691
+
692
+ // Scan the directory for .aps.md files
693
+ try {
694
+ const { readdir, stat } = await import('node:fs/promises');
695
+ const { join } = await import('node:path');
696
+
697
+ async function scanDir(dir: string): Promise<string[]> {
698
+ const files: string[] = [];
699
+ try {
700
+ const entries = await readdir(dir);
701
+ for (const entry of entries) {
702
+ const fullPath = join(dir, entry);
703
+ const stats = await stat(fullPath);
704
+ if (stats.isDirectory()) {
705
+ files.push(...(await scanDir(fullPath)));
706
+ } else if (entry.endsWith('.aps.md') && fullPath !== indexPath) {
707
+ files.push(fullPath);
708
+ }
709
+ }
710
+ } catch {
711
+ // Ignore errors (permission denied, etc.)
712
+ }
713
+ return files;
714
+ }
715
+
716
+ const allApsFiles = await scanDir(baseDir);
717
+
718
+ for (const file of allApsFiles) {
719
+ if (!linkedPaths.has(file)) {
720
+ issues.push({
721
+ severity: 'warning',
722
+ message: `Orphan leaf spec found: "${file}" is not linked from the index file`,
723
+ rule: 'orphan-modules',
724
+ path: file,
725
+ });
726
+ }
727
+ }
728
+ } catch {
729
+ // Ignore directory scanning errors
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Create a ValidationResult from issues
735
+ */
736
+ function createResult(issues: ValidationIssue[]): ValidationResult {
737
+ const errors = issues.filter((i) => i.severity === 'error');
738
+ const warnings = issues.filter((i) => i.severity === 'warning');
739
+
740
+ return {
741
+ valid: errors.length === 0,
742
+ issues,
743
+ errors,
744
+ warnings,
745
+ };
746
+ }
747
+
748
+ /**
749
+ * Format validation issues for display
750
+ */
751
+ export function formatValidationIssues(result: ValidationResult): string {
752
+ if (result.issues.length === 0) {
753
+ return 'No issues found.';
754
+ }
755
+
756
+ const lines: string[] = [];
757
+
758
+ for (const issue of result.issues) {
759
+ const severity = issue.severity === 'error' ? 'ERROR' : 'WARN';
760
+ const location = issue.path
761
+ ? issue.lineNumber
762
+ ? `${issue.path}:${issue.lineNumber}`
763
+ : issue.path
764
+ : '';
765
+ const prefix = location ? `${location}: ` : '';
766
+ lines.push(`[${severity}] ${prefix}${issue.message}`);
767
+ if (issue.context) {
768
+ lines.push(` ${issue.context}`);
769
+ }
770
+ }
771
+
772
+ const summary = `\n${result.errors.length} error(s), ${result.warnings.length} warning(s)`;
773
+ lines.push(summary);
774
+
775
+ return lines.join('\n');
776
+ }