@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,249 @@
1
+ /**
2
+ * Filter module - Task and module filtering for APS plans
3
+ *
4
+ * Provides filtering capabilities for:
5
+ * - Scope-based filtering (by module scope or task scopes)
6
+ * - Module-based filtering (by module ID)
7
+ * - Task-based filtering (by task ID)
8
+ * - Metadata filtering (owner, tags, priority, confidence)
9
+ *
10
+ * Also provides context bundle generation for LLM consumption.
11
+ */
12
+
13
+ // Re-export context bundle functions
14
+ export {
15
+ buildContextBundleJSON,
16
+ buildContextBundleText,
17
+ buildTaskContext,
18
+ type ContextBundleJSON,
19
+ } from './context-bundle.js';
20
+
21
+ import type { LoadedPlan, LoadedModule } from '../loader/index.js';
22
+ import type { Task, Priority, Confidence } from '../types/index.js';
23
+
24
+ /**
25
+ * Filter criteria for tasks and modules
26
+ */
27
+ export interface FilterCriteria {
28
+ /** Filter by scope (matches module scope or task scopes) */
29
+ scopes?: string[];
30
+
31
+ /** Filter by module ID */
32
+ modules?: string[];
33
+
34
+ /** Filter by specific task IDs */
35
+ tasks?: string[];
36
+
37
+ /** Filter by owner (e.g., @alice) */
38
+ owners?: string[];
39
+
40
+ /** Filter by tags (matches any) */
41
+ tags?: string[];
42
+
43
+ /** Filter by priority levels */
44
+ priorities?: Priority[];
45
+
46
+ /** Filter by confidence levels */
47
+ confidences?: Confidence[];
48
+
49
+ /** Filter by task status */
50
+ statuses?: Array<'open' | 'locked' | 'completed' | 'cancelled'>;
51
+ }
52
+
53
+ /**
54
+ * Result of filtering a plan
55
+ */
56
+ export interface FilteredPlan {
57
+ /** Original plan reference */
58
+ plan: LoadedPlan;
59
+
60
+ /** Filtered modules (only those matching criteria) */
61
+ modules: LoadedModule[];
62
+
63
+ /** Filtered tasks (only those matching criteria) */
64
+ tasks: Task[];
65
+
66
+ /** Applied filter criteria */
67
+ criteria: FilterCriteria;
68
+ }
69
+
70
+ /**
71
+ * Filter a loaded plan by the given criteria
72
+ *
73
+ * @param plan - The loaded plan to filter
74
+ * @param criteria - Filter criteria to apply
75
+ * @returns Filtered plan with matching modules and tasks
76
+ */
77
+ export function filterPlan(plan: LoadedPlan, criteria: FilterCriteria): FilteredPlan {
78
+ // Start with all modules and tasks
79
+ let filteredModules = Array.from(plan.modules.values());
80
+ let filteredTasks = [...plan.allTasks];
81
+
82
+ // Apply module-level filters first
83
+ if (criteria.modules && criteria.modules.length > 0) {
84
+ filteredModules = filteredModules.filter((m) => criteria.modules!.includes(m.id));
85
+ // Also filter tasks to only those in matching modules
86
+ const moduleIds = new Set(criteria.modules);
87
+ filteredTasks = filteredTasks.filter((t) => {
88
+ const taskModule = findTaskModule(plan, t.id);
89
+ return taskModule && moduleIds.has(taskModule.id);
90
+ });
91
+ }
92
+
93
+ // Apply scope filter (matches module scope OR task scopes)
94
+ if (criteria.scopes && criteria.scopes.length > 0) {
95
+ const scopeSet = new Set(criteria.scopes.map((s) => s.toUpperCase()));
96
+
97
+ filteredModules = filteredModules.filter((m) => {
98
+ const moduleScope = m.metadata.scope?.toUpperCase();
99
+ return moduleScope && scopeSet.has(moduleScope);
100
+ });
101
+
102
+ filteredTasks = filteredTasks.filter((t) => {
103
+ // Check if any of the task's scopes match
104
+ if (t.scopes && t.scopes.length > 0) {
105
+ return t.scopes.some((s) => scopeSet.has(s.toUpperCase()));
106
+ }
107
+ // Fall back to checking if task ID prefix matches scope
108
+ const taskScope = t.id.split('-')[0];
109
+ return scopeSet.has(taskScope);
110
+ });
111
+ }
112
+
113
+ // Apply task ID filter
114
+ if (criteria.tasks && criteria.tasks.length > 0) {
115
+ const taskIdSet = new Set(criteria.tasks);
116
+ filteredTasks = filteredTasks.filter((t) => taskIdSet.has(t.id));
117
+ }
118
+
119
+ // Apply owner filter
120
+ if (criteria.owners && criteria.owners.length > 0) {
121
+ const ownerSet = new Set(criteria.owners.map((o) => o.toLowerCase()));
122
+
123
+ filteredModules = filteredModules.filter((m) => {
124
+ const owner = m.metadata.owner?.toLowerCase();
125
+ return owner && ownerSet.has(owner);
126
+ });
127
+
128
+ // For tasks, filter by the module owner (tasks don't have individual owners)
129
+ filteredTasks = filteredTasks.filter((t) => {
130
+ const taskModule = findTaskModule(plan, t.id);
131
+ if (!taskModule) return false;
132
+ const owner = taskModule.metadata.owner?.toLowerCase();
133
+ return owner && ownerSet.has(owner);
134
+ });
135
+ }
136
+
137
+ // Apply tag filter (matches any tag)
138
+ if (criteria.tags && criteria.tags.length > 0) {
139
+ const tagSet = new Set(criteria.tags.map((t) => t.toLowerCase()));
140
+
141
+ filteredModules = filteredModules.filter((m) => {
142
+ const moduleTags = m.metadata.tags?.map((t) => t.toLowerCase()) ?? [];
143
+ return moduleTags.some((t) => tagSet.has(t));
144
+ });
145
+
146
+ filteredTasks = filteredTasks.filter((t) => {
147
+ const taskTags = t.tags?.map((tag) => tag.toLowerCase()) ?? [];
148
+ return taskTags.some((tag) => tagSet.has(tag));
149
+ });
150
+ }
151
+
152
+ // Apply priority filter
153
+ if (criteria.priorities && criteria.priorities.length > 0) {
154
+ const prioritySet = new Set(criteria.priorities);
155
+
156
+ filteredModules = filteredModules.filter((m) => {
157
+ return m.metadata.priority && prioritySet.has(m.metadata.priority);
158
+ });
159
+
160
+ // Tasks don't have priority, so filter by module priority
161
+ filteredTasks = filteredTasks.filter((t) => {
162
+ const taskModule = findTaskModule(plan, t.id);
163
+ return taskModule?.metadata.priority && prioritySet.has(taskModule.metadata.priority);
164
+ });
165
+ }
166
+
167
+ // Apply confidence filter
168
+ if (criteria.confidences && criteria.confidences.length > 0) {
169
+ const confidenceSet = new Set(criteria.confidences);
170
+ filteredTasks = filteredTasks.filter((t) => confidenceSet.has(t.confidence));
171
+ }
172
+
173
+ // Apply status filter
174
+ if (criteria.statuses && criteria.statuses.length > 0) {
175
+ const statusSet = new Set(criteria.statuses);
176
+ filteredTasks = filteredTasks.filter((t) => {
177
+ const status = t.status ?? 'open';
178
+ return statusSet.has(status);
179
+ });
180
+ }
181
+
182
+ return {
183
+ plan,
184
+ modules: filteredModules,
185
+ tasks: filteredTasks,
186
+ criteria,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Find the module that contains a task
192
+ */
193
+ function findTaskModule(plan: LoadedPlan, taskId: string): LoadedModule | undefined {
194
+ for (const module of plan.modules.values()) {
195
+ if (module.tasks.some((t) => t.id === taskId)) {
196
+ return module;
197
+ }
198
+ }
199
+ return undefined;
200
+ }
201
+
202
+ /**
203
+ * Filter tasks by scope (convenience function)
204
+ */
205
+ export function filterByScope(plan: LoadedPlan, scopes: string[]): Task[] {
206
+ return filterPlan(plan, { scopes }).tasks;
207
+ }
208
+
209
+ /**
210
+ * Filter tasks by module (convenience function)
211
+ */
212
+ export function filterByModule(plan: LoadedPlan, moduleIds: string[]): Task[] {
213
+ return filterPlan(plan, { modules: moduleIds }).tasks;
214
+ }
215
+
216
+ /**
217
+ * Filter tasks by tags (convenience function)
218
+ */
219
+ export function filterByTags(plan: LoadedPlan, tags: string[]): Task[] {
220
+ return filterPlan(plan, { tags }).tasks;
221
+ }
222
+
223
+ /**
224
+ * Filter tasks by owner (convenience function)
225
+ */
226
+ export function filterByOwner(plan: LoadedPlan, owners: string[]): Task[] {
227
+ return filterPlan(plan, { owners }).tasks;
228
+ }
229
+
230
+ /**
231
+ * Filter tasks by priority (convenience function)
232
+ */
233
+ export function filterByPriority(plan: LoadedPlan, priorities: Priority[]): Task[] {
234
+ return filterPlan(plan, { priorities }).tasks;
235
+ }
236
+
237
+ /**
238
+ * Filter tasks by confidence (convenience function)
239
+ */
240
+ export function filterByConfidence(plan: LoadedPlan, confidences: Confidence[]): Task[] {
241
+ return filterPlan(plan, { confidences }).tasks;
242
+ }
243
+
244
+ /**
245
+ * Get tasks matching specific IDs
246
+ */
247
+ export function getTasksById(plan: LoadedPlan, taskIds: string[]): Task[] {
248
+ return filterPlan(plan, { tasks: taskIds }).tasks;
249
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @eddacraft/anvil-aps - Anvil Planning Spec library
3
+ *
4
+ * This package provides functionality for parsing, loading, validating,
5
+ * and managing Anvil Planning Spec (APS) documents.
6
+ *
7
+ * @module @eddacraft/anvil-aps
8
+ */
9
+
10
+ export * from './parser/index.js';
11
+ export * from './loader/index.js';
12
+ export * from './filter/index.js';
13
+ export * from './validator/index.js';
14
+ export * from './state/index.js';
15
+ export * from './types/index.js';
16
+ export * from './templates/index.js';
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Plan loader module
3
+ * Loads and resolves APS planning documents into a graph structure
4
+ */
5
+
6
+ import { promises as fs } from 'node:fs';
7
+ import { dirname, resolve, isAbsolute, sep } from 'node:path';
8
+ import { unified } from 'unified';
9
+ import remarkParse from 'remark-parse';
10
+ import { visit } from 'unist-util-visit';
11
+ import type { Root, Heading } from 'mdast';
12
+ import { parseDocument } from '../parser/parse-document.js';
13
+ import { parseIndex } from '../parser/parse-index.js';
14
+ import { ParseError, type Task, type ModuleMetadata } from '../types/index.js';
15
+
16
+ /**
17
+ * A loaded module with its tasks and metadata
18
+ */
19
+ export interface LoadedModule {
20
+ /** Module identifier */
21
+ id: string;
22
+
23
+ /** Module metadata from index or leaf spec */
24
+ metadata: ModuleMetadata;
25
+
26
+ /** All tasks in this module */
27
+ tasks: Task[];
28
+
29
+ /** Resolved absolute path to the module file */
30
+ resolvedPath: string;
31
+
32
+ /** IDs of modules this module depends on */
33
+ dependsOn: string[];
34
+ }
35
+
36
+ /**
37
+ * A complete loaded plan with all modules resolved
38
+ */
39
+ export interface LoadedPlan {
40
+ /** Plan title */
41
+ title: string;
42
+
43
+ /** Root file path */
44
+ rootPath: string;
45
+
46
+ /** Whether this is a single-file plan or multi-module */
47
+ isMultiModule: boolean;
48
+
49
+ /** All loaded modules */
50
+ modules: Map<string, LoadedModule>;
51
+
52
+ /** All tasks from all modules (flattened) */
53
+ allTasks: Task[];
54
+
55
+ /** Dependency graph (module ID -> dependent module IDs) */
56
+ dependencyGraph: Map<string, string[]>;
57
+ }
58
+
59
+ /**
60
+ * Options for loading a plan
61
+ */
62
+ export interface LoadOptions {
63
+ /** Base directory for resolving relative paths (defaults to directory of root file) */
64
+ baseDir?: string;
65
+
66
+ /** Whether to recursively load linked modules (default: true) */
67
+ recursive?: boolean;
68
+
69
+ /** Maximum depth for recursive loading (default: 10) */
70
+ maxDepth?: number;
71
+ }
72
+
73
+ /**
74
+ * Load an APS plan from a file path
75
+ *
76
+ * @param filePath - Path to the root plan file (index or leaf spec)
77
+ * @param options - Loading options
78
+ * @returns Loaded plan with all modules resolved
79
+ */
80
+ export async function loadPlan(filePath: string, options: LoadOptions = {}): Promise<LoadedPlan> {
81
+ const absolutePath = isAbsolute(filePath) ? filePath : resolve(filePath);
82
+ const baseDir = options.baseDir ?? dirname(absolutePath);
83
+ const recursive = options.recursive ?? true;
84
+ const maxDepth = options.maxDepth ?? 10;
85
+
86
+ const content = await readFile(absolutePath);
87
+
88
+ // Try to detect if this is an index file or leaf spec
89
+ const isIndex = detectIndexFile(content);
90
+
91
+ if (isIndex) {
92
+ return loadMultiModulePlan(absolutePath, content, baseDir, recursive, maxDepth);
93
+ } else {
94
+ return loadSingleFilePlan(absolutePath, content);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Detect if content is an index file (has ## Modules section)
100
+ * Uses AST parsing for robust detection (case-insensitive, handles variants)
101
+ */
102
+ function detectIndexFile(content: string): boolean {
103
+ const processor = unified().use(remarkParse);
104
+ const ast = processor.parse(content) as Root;
105
+
106
+ let hasModulesSection = false;
107
+
108
+ visit(ast, 'heading', (node: Heading) => {
109
+ if (node.depth === 2) {
110
+ // Extract heading text and normalize
111
+ let text = '';
112
+ visit(node, 'text', (textNode: { value: string }) => {
113
+ text += textNode.value;
114
+ });
115
+
116
+ // Check if heading starts with "modules" (case-insensitive)
117
+ // This handles "## Modules", "## modules", "## Modules & Scopes", etc.
118
+ const normalizedText = text.trim().toLowerCase();
119
+ if (normalizedText === 'modules' || normalizedText.startsWith('modules')) {
120
+ hasModulesSection = true;
121
+ }
122
+ }
123
+ });
124
+
125
+ return hasModulesSection;
126
+ }
127
+
128
+ /**
129
+ * Load a single-file plan (leaf spec with tasks)
130
+ */
131
+ async function loadSingleFilePlan(filePath: string, content: string): Promise<LoadedPlan> {
132
+ const doc = await parseDocument(content, filePath);
133
+
134
+ const moduleId = doc.metadata?.scope ?? 'main';
135
+ const module: LoadedModule = {
136
+ id: moduleId,
137
+ metadata: doc.metadata ?? {},
138
+ tasks: doc.tasks,
139
+ resolvedPath: filePath,
140
+ dependsOn: [],
141
+ };
142
+
143
+ const modules = new Map<string, LoadedModule>();
144
+ modules.set(moduleId, module);
145
+
146
+ return {
147
+ title: doc.title,
148
+ rootPath: filePath,
149
+ isMultiModule: false,
150
+ modules,
151
+ allTasks: doc.tasks,
152
+ dependencyGraph: new Map([[moduleId, []]]),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Load a multi-module plan from an index file
158
+ */
159
+ async function loadMultiModulePlan(
160
+ indexPath: string,
161
+ content: string,
162
+ baseDir: string,
163
+ recursive: boolean,
164
+ _maxDepth: number // TODO: implement depth limiting for nested index files
165
+ ): Promise<LoadedPlan> {
166
+ const index = await parseIndex(content, indexPath);
167
+
168
+ const modules = new Map<string, LoadedModule>();
169
+ const allTasks: Task[] = [];
170
+ const dependencyGraph = new Map<string, string[]>();
171
+
172
+ // Load each module
173
+ for (const moduleMeta of index.modules) {
174
+ const moduleId = moduleMeta.id;
175
+ if (!moduleId) {
176
+ throw new ParseError('Module is missing required id field', indexPath);
177
+ }
178
+
179
+ if (!moduleMeta.path) {
180
+ throw new ParseError(`Module "${moduleId}" is missing required Path field`, indexPath);
181
+ }
182
+
183
+ const resolvedPath = resolvePath(moduleMeta.path, baseDir);
184
+
185
+ if (recursive) {
186
+ const moduleContent = await readFile(resolvedPath);
187
+ const moduleDoc = await parseDocument(moduleContent, resolvedPath);
188
+
189
+ // Merge metadata from index with any from the leaf spec
190
+ const mergedMetadata: ModuleMetadata = {
191
+ ...moduleDoc.metadata,
192
+ ...moduleMeta,
193
+ id: moduleId,
194
+ };
195
+
196
+ const loadedModule: LoadedModule = {
197
+ id: moduleId,
198
+ metadata: mergedMetadata,
199
+ tasks: moduleDoc.tasks,
200
+ resolvedPath,
201
+ dependsOn: moduleMeta.dependencies ?? [],
202
+ };
203
+
204
+ modules.set(moduleId, loadedModule);
205
+ allTasks.push(...moduleDoc.tasks);
206
+ dependencyGraph.set(moduleId, moduleMeta.dependencies ?? []);
207
+ } else {
208
+ // Non-recursive: just record module metadata without loading content
209
+ const loadedModule: LoadedModule = {
210
+ id: moduleId,
211
+ metadata: moduleMeta,
212
+ tasks: [],
213
+ resolvedPath,
214
+ dependsOn: moduleMeta.dependencies ?? [],
215
+ };
216
+
217
+ modules.set(moduleId, loadedModule);
218
+ dependencyGraph.set(moduleId, moduleMeta.dependencies ?? []);
219
+ }
220
+ }
221
+
222
+ return {
223
+ title: index.title,
224
+ rootPath: indexPath,
225
+ isMultiModule: true,
226
+ modules,
227
+ allTasks,
228
+ dependencyGraph,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Resolve a relative path against a base directory.
234
+ * Rejects absolute paths and paths that escape the base directory.
235
+ */
236
+ export function resolvePath(relativePath: string, baseDir: string): string {
237
+ // Reject absolute paths — module paths must be relative to baseDir
238
+ if (isAbsolute(relativePath)) {
239
+ throw new ParseError(`Absolute module paths are not allowed: ${relativePath}`, relativePath);
240
+ }
241
+
242
+ // Remove leading ./ if present
243
+ const cleanPath = relativePath.replace(/^\.\//, '');
244
+ const resolved = resolve(baseDir, cleanPath);
245
+ const resolvedBase = resolve(baseDir);
246
+
247
+ // Validate the resolved path stays within baseDir
248
+ if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + sep)) {
249
+ throw new ParseError(`Module path escapes base directory: ${relativePath}`, relativePath);
250
+ }
251
+
252
+ return resolved;
253
+ }
254
+
255
+ /**
256
+ * Read a file with proper error handling
257
+ */
258
+ async function readFile(filePath: string): Promise<string> {
259
+ try {
260
+ return await fs.readFile(filePath, 'utf-8');
261
+ } catch (error) {
262
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
263
+ throw new ParseError(`File not found: ${filePath}`, filePath);
264
+ }
265
+ throw new ParseError(
266
+ `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
267
+ filePath
268
+ );
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Get all tasks for a specific module
274
+ */
275
+ export function getModuleTasks(plan: LoadedPlan, moduleId: string): Task[] {
276
+ const module = plan.modules.get(moduleId);
277
+ return module?.tasks ?? [];
278
+ }
279
+
280
+ /**
281
+ * Get all modules that depend on a specific module
282
+ */
283
+ export function getDependentModules(plan: LoadedPlan, moduleId: string): string[] {
284
+ const dependents: string[] = [];
285
+
286
+ for (const [id, deps] of plan.dependencyGraph) {
287
+ if (deps.includes(moduleId)) {
288
+ dependents.push(id);
289
+ }
290
+ }
291
+
292
+ return dependents;
293
+ }
294
+
295
+ /**
296
+ * Get modules in topological order (dependencies first)
297
+ */
298
+ export function getModulesInOrder(plan: LoadedPlan): string[] {
299
+ const visited = new Set<string>();
300
+ const result: string[] = [];
301
+
302
+ function visit(moduleId: string) {
303
+ if (visited.has(moduleId)) return;
304
+ visited.add(moduleId);
305
+
306
+ const deps = plan.dependencyGraph.get(moduleId) ?? [];
307
+ for (const dep of deps) {
308
+ visit(dep);
309
+ }
310
+
311
+ result.push(moduleId);
312
+ }
313
+
314
+ for (const moduleId of plan.modules.keys()) {
315
+ visit(moduleId);
316
+ }
317
+
318
+ return result;
319
+ }
320
+
321
+ /**
322
+ * Check for circular dependencies in the plan
323
+ */
324
+ export function detectCycles(plan: LoadedPlan): string[][] {
325
+ const cycles: string[][] = [];
326
+ const visited = new Set<string>();
327
+ const recursionStack = new Set<string>();
328
+ const path: string[] = [];
329
+
330
+ function dfs(moduleId: string): boolean {
331
+ visited.add(moduleId);
332
+ recursionStack.add(moduleId);
333
+ path.push(moduleId);
334
+
335
+ const deps = plan.dependencyGraph.get(moduleId) ?? [];
336
+ for (const dep of deps) {
337
+ if (!visited.has(dep)) {
338
+ if (dfs(dep)) {
339
+ return true;
340
+ }
341
+ } else if (recursionStack.has(dep)) {
342
+ // Found a cycle
343
+ const cycleStart = path.indexOf(dep);
344
+ cycles.push([...path.slice(cycleStart), dep]);
345
+ }
346
+ }
347
+
348
+ path.pop();
349
+ recursionStack.delete(moduleId);
350
+ return false;
351
+ }
352
+
353
+ for (const moduleId of plan.modules.keys()) {
354
+ if (!visited.has(moduleId)) {
355
+ dfs(moduleId);
356
+ }
357
+ }
358
+
359
+ return cycles;
360
+ }
361
+
362
+ // Re-export types
363
+ export type { ParsedIndex } from '../parser/parse-index.js';
364
+ export type { ParsedDocument } from '../types/index.js';