@grainulation/orchard 1.0.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/lib/planner.js ADDED
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+
5
+ /**
6
+ * Build an adjacency list from sprint dependencies.
7
+ * Returns { nodes: Map<path, sprint>, edges: Map<path, Set<path>> }
8
+ */
9
+ function buildGraph(config) {
10
+ const nodes = new Map();
11
+ const edges = new Map();
12
+
13
+ for (const sprint of config.sprints || []) {
14
+ nodes.set(sprint.path, sprint);
15
+ edges.set(sprint.path, new Set());
16
+ }
17
+
18
+ for (const sprint of config.sprints || []) {
19
+ for (const dep of sprint.depends_on || []) {
20
+ if (edges.has(dep)) {
21
+ edges.get(dep).add(sprint.path);
22
+ }
23
+ }
24
+ }
25
+
26
+ return { nodes, edges };
27
+ }
28
+
29
+ /**
30
+ * Topological sort of sprints. Returns ordered array of sprint paths.
31
+ * Throws if a cycle is detected.
32
+ */
33
+ function topoSort(config) {
34
+ const sprints = config.sprints || [];
35
+ const inDegree = new Map();
36
+ const adj = new Map();
37
+
38
+ for (const s of sprints) {
39
+ inDegree.set(s.path, 0);
40
+ adj.set(s.path, []);
41
+ }
42
+
43
+ for (const s of sprints) {
44
+ for (const dep of s.depends_on || []) {
45
+ if (adj.has(dep)) {
46
+ adj.get(dep).push(s.path);
47
+ inDegree.set(s.path, (inDegree.get(s.path) || 0) + 1);
48
+ }
49
+ }
50
+ }
51
+
52
+ const queue = [];
53
+ for (const [p, deg] of inDegree) {
54
+ if (deg === 0) queue.push(p);
55
+ }
56
+
57
+ const order = [];
58
+ while (queue.length > 0) {
59
+ const curr = queue.shift();
60
+ order.push(curr);
61
+ for (const next of adj.get(curr) || []) {
62
+ const newDeg = inDegree.get(next) - 1;
63
+ inDegree.set(next, newDeg);
64
+ if (newDeg === 0) queue.push(next);
65
+ }
66
+ }
67
+
68
+ if (order.length !== sprints.length) {
69
+ const remaining = sprints.map((s) => s.path).filter((p) => !order.includes(p));
70
+ throw new Error(`Dependency cycle detected involving: ${remaining.join(', ')}`);
71
+ }
72
+
73
+ return order;
74
+ }
75
+
76
+ /**
77
+ * Detect dependency cycles. Returns array of cycle paths, empty if acyclic.
78
+ */
79
+ function detectCycles(config) {
80
+ try {
81
+ topoSort(config);
82
+ return [];
83
+ } catch {
84
+ return config.sprints.map((s) => s.path);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Print the dependency graph as ASCII art to stdout.
90
+ */
91
+ function printDependencyGraph(config, root) {
92
+ const sprints = config.sprints || [];
93
+
94
+ if (sprints.length === 0) {
95
+ console.log('No sprints configured in orchard.json.');
96
+ return;
97
+ }
98
+
99
+ console.log('');
100
+ console.log(` Sprint Dependency Graph (${sprints.length} sprints)`);
101
+ console.log(' ' + '='.repeat(50));
102
+ console.log('');
103
+
104
+ // Build lookup
105
+ const byPath = new Map();
106
+ for (const s of sprints) byPath.set(s.path, s);
107
+
108
+ let order;
109
+ try {
110
+ order = topoSort(config);
111
+ } catch (err) {
112
+ console.log(` ERROR: ${err.message}`);
113
+ return;
114
+ }
115
+
116
+ // Compute depth (longest path from root)
117
+ const depth = new Map();
118
+ for (const p of order) {
119
+ const s = byPath.get(p);
120
+ let maxDepth = 0;
121
+ for (const dep of s.depends_on || []) {
122
+ if (depth.has(dep)) {
123
+ maxDepth = Math.max(maxDepth, depth.get(dep) + 1);
124
+ }
125
+ }
126
+ depth.set(p, maxDepth);
127
+ }
128
+
129
+ // Group by depth
130
+ const levels = new Map();
131
+ for (const [p, d] of depth) {
132
+ if (!levels.has(d)) levels.set(d, []);
133
+ levels.get(d).push(p);
134
+ }
135
+
136
+ const maxLevel = Math.max(...levels.keys(), 0);
137
+
138
+ for (let level = 0; level <= maxLevel; level++) {
139
+ const items = levels.get(level) || [];
140
+ const prefix = level === 0 ? 'ROOT' : `L${level} `;
141
+
142
+ for (const p of items) {
143
+ const s = byPath.get(p);
144
+ const name = path.basename(p);
145
+ const status = s.status || 'unknown';
146
+ const assignee = s.assigned_to || 'unassigned';
147
+ const indent = ' '.repeat(level + 1);
148
+
149
+ const marker =
150
+ status === 'active' ? '[*]' :
151
+ status === 'done' ? '[x]' :
152
+ status === 'blocked' ? '[!]' :
153
+ '[ ]';
154
+
155
+ console.log(`${indent}${marker} ${name} (${assignee})`);
156
+
157
+ // Show what it depends on
158
+ for (const dep of s.depends_on || []) {
159
+ console.log(`${indent} <- ${path.basename(dep)}`);
160
+ }
161
+ }
162
+
163
+ if (level < maxLevel) {
164
+ console.log(' ' + ' '.repeat(level * 2) + ' |');
165
+ }
166
+ }
167
+
168
+ console.log('');
169
+
170
+ // Show deadline summary
171
+ const withDeadlines = sprints.filter((s) => s.deadline);
172
+ if (withDeadlines.length > 0) {
173
+ console.log(' Deadlines:');
174
+ withDeadlines
175
+ .sort((a, b) => a.deadline.localeCompare(b.deadline))
176
+ .forEach((s) => {
177
+ const name = path.basename(s.path);
178
+ const days = daysUntil(s.deadline);
179
+ const urgency = days < 0 ? 'OVERDUE' : days <= 3 ? 'URGENT' : `${days}d`;
180
+ console.log(` ${name}: ${s.deadline} (${urgency})`);
181
+ });
182
+ console.log('');
183
+ }
184
+ }
185
+
186
+ function daysUntil(dateStr) {
187
+ const target = new Date(dateStr);
188
+ const now = new Date();
189
+ return Math.ceil((target - now) / (1000 * 60 * 60 * 24));
190
+ }
191
+
192
+ module.exports = {
193
+ buildGraph,
194
+ topoSort,
195
+ detectCycles,
196
+ printDependencyGraph,
197
+ daysUntil,
198
+ };