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