@grainulation/orchard 1.0.1 → 1.0.2

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/conflicts.js CHANGED
@@ -1,26 +1,36 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const fs = require('node:fs');
4
- const path = require('node:path');
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
5
 
6
6
  /**
7
7
  * Load claims from a sprint directory.
8
8
  */
9
9
  function loadClaims(sprintPath, root) {
10
- const absPath = path.isAbsolute(sprintPath) ? sprintPath : path.join(root, sprintPath);
11
- const claimsPath = path.join(absPath, 'claims.json');
10
+ const absPath = path.isAbsolute(sprintPath)
11
+ ? sprintPath
12
+ : path.join(root, sprintPath);
13
+ const claimsPath = path.join(absPath, "claims.json");
12
14
 
13
15
  if (!fs.existsSync(claimsPath)) return [];
14
16
 
15
17
  try {
16
- const data = JSON.parse(fs.readFileSync(claimsPath, 'utf8'));
17
- const claims = Array.isArray(data) ? data : (data.claims || []);
18
+ const data = JSON.parse(fs.readFileSync(claimsPath, "utf8"));
19
+ const claims = Array.isArray(data) ? data : data.claims || [];
18
20
  return claims.map((c) => ({ ...c, _source: sprintPath }));
19
21
  } catch {
20
22
  return [];
21
23
  }
22
24
  }
23
25
 
26
+ /**
27
+ * Severity levels for conflicts:
28
+ * - critical: Opposing recommendations on same topic (needs immediate resolution)
29
+ * - warning: Constraint-recommendation tension (may need investigation)
30
+ * - info: Same-topic claims with different evidence tiers (terminology/approach differences)
31
+ */
32
+ const SEVERITY = { critical: "critical", warning: "warning", info: "info" };
33
+
24
34
  /**
25
35
  * Detect potential conflicts between claims across sprints.
26
36
  *
@@ -29,7 +39,7 @@ function loadClaims(sprintPath, root) {
29
39
  * 2. Constraints that conflict with recommendations from other sprints
30
40
  * 3. Estimates with non-overlapping ranges on the same topic
31
41
  *
32
- * Returns array of { type, claimA, claimB, reason }
42
+ * Returns array of { type, claimA, claimB, severity, reason, actions }
33
43
  */
34
44
  function detectConflicts(config, root) {
35
45
  const allClaims = [];
@@ -60,30 +70,51 @@ function detectConflicts(config, root) {
60
70
  // Only flag cross-sprint conflicts
61
71
  if (a._source === b._source) continue;
62
72
 
63
- // Opposing recommendations
64
- if (a.type === 'recommendation' && b.type === 'recommendation') {
65
- if (couldContradict(a.text, b.text)) {
73
+ // Opposing recommendations — critical severity
74
+ if (a.type === "recommendation" && b.type === "recommendation") {
75
+ if (couldContradict(a.text || a.content, b.text || b.content)) {
66
76
  conflicts.push({
67
- type: 'opposing-recommendations',
77
+ type: "opposing-recommendations",
68
78
  claimA: a,
69
79
  claimB: b,
70
80
  tag,
81
+ severity: SEVERITY.critical,
71
82
  reason: `Both sprints recommend on "${tag}" but may conflict`,
83
+ actions: ["trust-a", "trust-b", "investigate"],
72
84
  });
73
85
  }
74
86
  }
75
87
 
76
- // Constraint vs recommendation
88
+ // Constraint vs recommendation — warning severity
77
89
  if (
78
- (a.type === 'constraint' && b.type === 'recommendation') ||
79
- (a.type === 'recommendation' && b.type === 'constraint')
90
+ (a.type === "constraint" && b.type === "recommendation") ||
91
+ (a.type === "recommendation" && b.type === "constraint")
80
92
  ) {
81
93
  conflicts.push({
82
- type: 'constraint-recommendation-tension',
94
+ type: "constraint-recommendation-tension",
83
95
  claimA: a,
84
96
  claimB: b,
85
97
  tag,
98
+ severity: SEVERITY.warning,
86
99
  reason: `Constraint from ${a._source} may conflict with recommendation from ${b._source}`,
100
+ actions: ["trust-a", "trust-b", "investigate"],
101
+ });
102
+ }
103
+
104
+ // Same topic, different evidence tiers — info severity
105
+ if (
106
+ a.type === b.type &&
107
+ a.type === "factual" &&
108
+ a.evidence !== b.evidence
109
+ ) {
110
+ conflicts.push({
111
+ type: "evidence-tier-mismatch",
112
+ claimA: a,
113
+ claimB: b,
114
+ tag,
115
+ severity: SEVERITY.info,
116
+ reason: `Different evidence tiers on "${tag}": ${a.evidence || "unknown"} vs ${b.evidence || "unknown"}`,
117
+ actions: ["trust-a", "trust-b", "dismiss"],
87
118
  });
88
119
  }
89
120
  }
@@ -93,6 +124,15 @@ function detectConflicts(config, root) {
93
124
  return conflicts;
94
125
  }
95
126
 
127
+ /**
128
+ * Filter conflicts by severity level.
129
+ */
130
+ function filterBySeverity(conflicts, minSeverity) {
131
+ const order = { critical: 0, warning: 1, info: 2 };
132
+ const threshold = order[minSeverity] ?? 2;
133
+ return conflicts.filter((c) => (order[c.severity] ?? 2) <= threshold);
134
+ }
135
+
96
136
  /**
97
137
  * Simple heuristic: two texts might contradict if they share keywords
98
138
  * but use opposing qualifiers. This is intentionally conservative --
@@ -101,7 +141,17 @@ function detectConflicts(config, root) {
101
141
  function couldContradict(textA, textB) {
102
142
  if (!textA || !textB) return false;
103
143
 
104
- const negators = ['not', 'no', 'never', 'avoid', 'instead', 'rather', 'without', 'dont', "don't"];
144
+ const negators = [
145
+ "not",
146
+ "no",
147
+ "never",
148
+ "avoid",
149
+ "instead",
150
+ "rather",
151
+ "without",
152
+ "dont",
153
+ "don't",
154
+ ];
105
155
  const aWords = new Set(textA.toLowerCase().split(/\s+/));
106
156
  const bWords = new Set(textB.toLowerCase().split(/\s+/));
107
157
 
@@ -115,36 +165,134 @@ function couldContradict(textA, textB) {
115
165
  }
116
166
 
117
167
  /**
118
- * Print conflict report.
168
+ * Print conflict report with severity-based formatting.
119
169
  */
120
- function printConflicts(config, root) {
121
- const conflicts = detectConflicts(config, root);
170
+ function printConflicts(config, root, opts = {}) {
171
+ const all = detectConflicts(config, root);
172
+ const minSeverity = opts.severity || "info";
173
+ const conflicts = filterBySeverity(all, minSeverity);
122
174
 
123
175
  if (conflicts.length === 0) {
124
- console.log('');
125
- console.log(' No cross-sprint conflicts detected.');
126
- console.log('');
176
+ console.log("");
177
+ console.log(" No cross-sprint conflicts detected.");
178
+ console.log("");
127
179
  return;
128
180
  }
129
181
 
130
- console.log('');
131
- console.log(` ${conflicts.length} potential conflict(s) detected`);
132
- console.log(' ' + '='.repeat(50));
182
+ const severityIcon = { critical: "!!!", warning: "!!", info: "(i)" };
183
+ const critical = conflicts.filter((c) => c.severity === "critical");
184
+ const warning = conflicts.filter((c) => c.severity === "warning");
185
+ const info = conflicts.filter((c) => c.severity === "info");
186
+
187
+ console.log("");
188
+ console.log(` ${conflicts.length} conflict(s) detected`);
189
+ if (critical.length) console.log(` ${critical.length} critical`);
190
+ if (warning.length) console.log(` ${warning.length} warning`);
191
+ if (info.length) console.log(` ${info.length} info`);
192
+ console.log(" " + "=".repeat(50));
133
193
 
134
194
  for (const c of conflicts) {
135
- console.log('');
136
- console.log(` [${c.type}] tag: ${c.tag}`);
195
+ const icon = severityIcon[c.severity] || "?";
196
+ const textA = (c.claimA.text || c.claimA.content || "").substring(0, 80);
197
+ const textB = (c.claimB.text || c.claimB.content || "").substring(0, 80);
198
+
199
+ console.log("");
200
+ console.log(` ${icon} [${c.severity}] ${c.type} — tag: ${c.tag}`);
137
201
  console.log(` Sprint A: ${c.claimA._source} (${c.claimA.id})`);
202
+ if (textA) console.log(` "${textA}"`);
138
203
  console.log(` Sprint B: ${c.claimB._source} (${c.claimB.id})`);
204
+ if (textB) console.log(` "${textB}"`);
139
205
  console.log(` Reason: ${c.reason}`);
206
+ if (c.actions) {
207
+ console.log(` Actions: ${c.actions.join(" | ")}`);
208
+ }
209
+ }
210
+
211
+ console.log("");
212
+ }
213
+
214
+ /**
215
+ * Scan for cross-sprint conflicts by scanning a directory tree for claims.json files.
216
+ * Does not require orchard.json -- discovers sprints automatically.
217
+ *
218
+ * @param {string} rootDir - Root directory to scan for sprint subdirectories
219
+ * @param {object} [opts] - Options
220
+ * @param {string} [opts.severity] - Minimum severity to include (default: 'info')
221
+ * @param {number} [opts.maxDepth] - Max directory depth to scan (default: 3)
222
+ * @returns {object} - { conflicts, summary, sprintsScanned }
223
+ */
224
+ function scanAllConflicts(rootDir, opts = {}) {
225
+ const maxDepth = opts.maxDepth || 3;
226
+ const minSeverity = opts.severity || "info";
227
+
228
+ // Discover sprint directories by finding claims.json files
229
+ const sprintPaths = discoverSprints(rootDir, maxDepth);
230
+
231
+ if (sprintPaths.length < 2) {
232
+ return {
233
+ conflicts: [],
234
+ summary: { total: 0, critical: 0, warning: 0, info: 0 },
235
+ sprintsScanned: sprintPaths.length,
236
+ };
237
+ }
238
+
239
+ // Build a synthetic config for detectConflicts
240
+ const config = {
241
+ sprints: sprintPaths.map((p) => ({ path: p })),
242
+ };
243
+
244
+ const all = detectConflicts(config, rootDir);
245
+ const filtered = filterBySeverity(all, minSeverity);
246
+
247
+ const critical = filtered.filter((c) => c.severity === "critical").length;
248
+ const warning = filtered.filter((c) => c.severity === "warning").length;
249
+ const info = filtered.filter((c) => c.severity === "info").length;
250
+
251
+ return {
252
+ conflicts: filtered,
253
+ summary: { total: filtered.length, critical, warning, info },
254
+ sprintsScanned: sprintPaths.length,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Recursively discover sprint directories (directories containing claims.json).
260
+ */
261
+ function discoverSprints(dir, maxDepth, currentDepth) {
262
+ if (currentDepth === undefined) currentDepth = 0;
263
+ if (currentDepth > maxDepth) return [];
264
+
265
+ const results = [];
266
+
267
+ // Check if this directory has claims.json
268
+ const claimsPath = path.join(dir, "claims.json");
269
+ if (fs.existsSync(claimsPath)) {
270
+ results.push(dir);
271
+ }
272
+
273
+ // Recurse into subdirectories
274
+ try {
275
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
276
+ for (const entry of entries) {
277
+ if (!entry.isDirectory()) continue;
278
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
279
+ const childDir = path.join(dir, entry.name);
280
+ results.push(...discoverSprints(childDir, maxDepth, currentDepth + 1));
281
+ }
282
+ } catch {
283
+ // skip unreadable directories
140
284
  }
141
285
 
142
- console.log('');
286
+ return results;
143
287
  }
144
288
 
145
289
  module.exports = {
146
290
  loadClaims,
147
291
  detectConflicts,
148
292
  couldContradict,
293
+ filterBySeverity,
149
294
  printConflicts,
295
+ scanAllConflicts,
296
+ discoverSprints,
297
+ SEVERITY,
150
298
  };
package/lib/dashboard.js CHANGED
@@ -1,7 +1,7 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const fs = require('node:fs');
4
- const path = require('node:path');
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
5
 
6
6
  /**
7
7
  * Scan for claims.json files in target directory.
@@ -11,17 +11,27 @@ function findSprintFiles(targetDir) {
11
11
  const found = [];
12
12
 
13
13
  // Direct claims.json in target dir
14
- const direct = path.join(targetDir, 'claims.json');
14
+ const direct = path.join(targetDir, "claims.json");
15
15
  if (fs.existsSync(direct)) {
16
- found.push({ file: direct, dir: targetDir, name: path.basename(targetDir), cat: 'root' });
16
+ found.push({
17
+ file: direct,
18
+ dir: targetDir,
19
+ name: path.basename(targetDir),
20
+ cat: "root",
21
+ });
17
22
  }
18
23
 
19
24
  // Archive subdir (flat JSON files)
20
- const archiveDir = path.join(targetDir, 'archive');
25
+ const archiveDir = path.join(targetDir, "archive");
21
26
  if (fs.existsSync(archiveDir) && fs.statSync(archiveDir).isDirectory()) {
22
27
  for (const f of fs.readdirSync(archiveDir)) {
23
- if (f.endsWith('.json') && f.includes('claims')) {
24
- found.push({ file: path.join(archiveDir, f), dir: archiveDir, name: f.replace('.json', '').replace(/-/g, ' '), cat: 'archive' });
28
+ if (f.endsWith(".json") && f.includes("claims")) {
29
+ found.push({
30
+ file: path.join(archiveDir, f),
31
+ dir: archiveDir,
32
+ name: f.replace(".json", "").replace(/-/g, " "),
33
+ cat: "archive",
34
+ });
25
35
  }
26
36
  }
27
37
  }
@@ -31,27 +41,46 @@ function findSprintFiles(targetDir) {
31
41
  const entries = fs.readdirSync(targetDir, { withFileTypes: true });
32
42
  for (const entry of entries) {
33
43
  if (!entry.isDirectory()) continue;
34
- if (entry.name.startsWith('.') || entry.name === 'archive' || entry.name === 'node_modules') continue;
44
+ if (
45
+ entry.name.startsWith(".") ||
46
+ entry.name === "archive" ||
47
+ entry.name === "node_modules"
48
+ )
49
+ continue;
35
50
  const childDir = path.join(targetDir, entry.name);
36
- const childClaims = path.join(childDir, 'claims.json');
51
+ const childClaims = path.join(childDir, "claims.json");
37
52
  if (fs.existsSync(childClaims)) {
38
- found.push({ file: childClaims, dir: childDir, name: entry.name, cat: 'active' });
53
+ found.push({
54
+ file: childClaims,
55
+ dir: childDir,
56
+ name: entry.name,
57
+ cat: "active",
58
+ });
39
59
  }
40
60
  // Second level
41
61
  try {
42
62
  const subEntries = fs.readdirSync(childDir, { withFileTypes: true });
43
63
  for (const sub of subEntries) {
44
64
  if (!sub.isDirectory()) continue;
45
- if (sub.name.startsWith('.')) continue;
65
+ if (sub.name.startsWith(".")) continue;
46
66
  const subDir = path.join(childDir, sub.name);
47
- const subClaims = path.join(subDir, 'claims.json');
67
+ const subClaims = path.join(subDir, "claims.json");
48
68
  if (fs.existsSync(subClaims)) {
49
- found.push({ file: subClaims, dir: subDir, name: sub.name, cat: 'active' });
69
+ found.push({
70
+ file: subClaims,
71
+ dir: subDir,
72
+ name: sub.name,
73
+ cat: "active",
74
+ });
50
75
  }
51
76
  }
52
- } catch { /* skip */ }
77
+ } catch {
78
+ /* skip */
79
+ }
53
80
  }
54
- } catch { /* skip */ }
81
+ } catch {
82
+ /* skip */
83
+ }
55
84
 
56
85
  return found;
57
86
  }
@@ -61,9 +90,9 @@ function findSprintFiles(targetDir) {
61
90
  */
62
91
  function parseClaims(filePath) {
63
92
  try {
64
- const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
93
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
65
94
  const meta = raw.meta || {};
66
- const claims = Array.isArray(raw) ? raw : (raw.claims || []);
95
+ const claims = Array.isArray(raw) ? raw : raw.claims || [];
67
96
  return { meta, claims };
68
97
  } catch {
69
98
  return null;
@@ -92,8 +121,8 @@ function loadSprints(targetDir) {
92
121
 
93
122
  sprints.push({
94
123
  path: src.dir,
95
- question: meta.question || '(no question)',
96
- phase: meta.phase || 'unknown',
124
+ question: meta.question || "(no question)",
125
+ phase: meta.phase || "unknown",
97
126
  claimCount: claims.length,
98
127
  topics: [...topicSet],
99
128
  initiated: meta.initiated || null,
@@ -106,12 +135,12 @@ function loadSprints(targetDir) {
106
135
  for (const sprint of sprints) {
107
136
  for (const claim of sprint._claims) {
108
137
  // Check resolved_by for cross-sprint references
109
- if (claim.resolved_by && typeof claim.resolved_by === 'string') {
138
+ if (claim.resolved_by && typeof claim.resolved_by === "string") {
110
139
  for (const other of sprints) {
111
140
  if (other.path === sprint.path) continue;
112
141
  const dirName = path.basename(other.path);
113
142
  if (claim.resolved_by.includes(dirName)) {
114
- addEdge(edges, sprint.path, other.path, 'resolved_by');
143
+ addEdge(edges, sprint.path, other.path, "resolved_by");
115
144
  break;
116
145
  }
117
146
  }
@@ -120,12 +149,12 @@ function loadSprints(targetDir) {
120
149
  // Check conflicts_with
121
150
  if (Array.isArray(claim.conflicts_with)) {
122
151
  for (const ref of claim.conflicts_with) {
123
- if (typeof ref !== 'string') continue;
152
+ if (typeof ref !== "string") continue;
124
153
  for (const other of sprints) {
125
154
  if (other.path === sprint.path) continue;
126
155
  const dirName = path.basename(other.path);
127
156
  if (ref.includes(dirName)) {
128
- addEdge(edges, sprint.path, other.path, 'conflict');
157
+ addEdge(edges, sprint.path, other.path, "conflict");
129
158
  break;
130
159
  }
131
160
  }
@@ -133,13 +162,13 @@ function loadSprints(targetDir) {
133
162
  }
134
163
 
135
164
  // Check content for sprint name references
136
- const content = claim.content || claim.text || '';
137
- if (typeof content === 'string') {
165
+ const content = claim.content || claim.text || "";
166
+ if (typeof content === "string") {
138
167
  for (const other of sprints) {
139
168
  if (other.path === sprint.path) continue;
140
169
  const sprintName = path.basename(other.path);
141
170
  if (sprintName.length > 3 && content.includes(sprintName)) {
142
- addEdge(edges, sprint.path, other.path, 'reference');
171
+ addEdge(edges, sprint.path, other.path, "reference");
143
172
  }
144
173
  }
145
174
  }
@@ -150,20 +179,24 @@ function loadSprints(targetDir) {
150
179
  const cycles = detectCycles(sprints, edges);
151
180
 
152
181
  // Build status array
153
- const status = sprints.map(s => {
154
- let active = 0, resolved = 0, superseded = 0, other = 0;
182
+ const status = sprints.map((s) => {
183
+ let active = 0,
184
+ resolved = 0,
185
+ superseded = 0,
186
+ other = 0;
155
187
  let latestTimestamp = null;
156
188
  const topicCounts = {};
157
189
 
158
190
  for (const c of s._claims) {
159
- const st = (c.status || '').toLowerCase();
160
- if (st === 'active') active++;
161
- else if (st === 'resolved') resolved++;
162
- else if (st === 'superseded') superseded++;
191
+ const st = (c.status || "").toLowerCase();
192
+ if (st === "active") active++;
193
+ else if (st === "resolved") resolved++;
194
+ else if (st === "superseded") superseded++;
163
195
  else other++;
164
196
 
165
197
  if (c.timestamp) {
166
- if (!latestTimestamp || c.timestamp > latestTimestamp) latestTimestamp = c.timestamp;
198
+ if (!latestTimestamp || c.timestamp > latestTimestamp)
199
+ latestTimestamp = c.timestamp;
167
200
  }
168
201
  if (c.topic) {
169
202
  topicCounts[c.topic] = (topicCounts[c.topic] || 0) + 1;
@@ -198,7 +231,7 @@ function loadSprints(targetDir) {
198
231
  });
199
232
 
200
233
  // Strip _claims from sprint objects before returning
201
- const cleanSprints = sprints.map(s => {
234
+ const cleanSprints = sprints.map((s) => {
202
235
  const { _claims, ...rest } = s;
203
236
  return rest;
204
237
  });
@@ -207,7 +240,9 @@ function loadSprints(targetDir) {
207
240
  }
208
241
 
209
242
  function addEdge(edges, from, to, type) {
210
- const existing = edges.find(e => e.from === from && e.to === to && e.type === type);
243
+ const existing = edges.find(
244
+ (e) => e.from === from && e.to === to && e.type === type,
245
+ );
211
246
  if (existing) {
212
247
  existing.count++;
213
248
  } else {
@@ -224,18 +259,20 @@ function detectCycles(sprints, edges) {
224
259
  adj[e.from].push(e.to);
225
260
  }
226
261
 
227
- const WHITE = 0, GRAY = 1, BLACK = 2;
262
+ const WHITE = 0,
263
+ GRAY = 1,
264
+ BLACK = 2;
228
265
  const color = {};
229
266
  for (const s of sprints) color[s.path] = WHITE;
230
267
 
231
268
  function dfs(u, pathStack) {
232
269
  color[u] = GRAY;
233
270
  pathStack.push(u);
234
- for (const v of (adj[u] || [])) {
271
+ for (const v of adj[u] || []) {
235
272
  if (color[v] === GRAY) {
236
273
  const cycleStart = pathStack.indexOf(v);
237
274
  if (cycleStart !== -1) {
238
- cycles.push(pathStack.slice(cycleStart).map(p => path.basename(p)));
275
+ cycles.push(pathStack.slice(cycleStart).map((p) => path.basename(p)));
239
276
  }
240
277
  } else if (color[v] === WHITE) {
241
278
  dfs(v, pathStack);
@@ -258,17 +295,25 @@ function detectCycles(sprints, edges) {
258
295
  * @returns {string} Complete HTML string
259
296
  */
260
297
  function buildHtml(graphData) {
261
- const templatePath = path.join(__dirname, '..', 'templates', 'dashboard.html');
262
- const template = fs.readFileSync(templatePath, 'utf8');
263
- const jsonData = JSON.stringify(graphData).replace(/<\/script/gi, '<\\/script');
264
- return template.replace('__SPRINT_DATA__', jsonData);
298
+ const templatePath = path.join(
299
+ __dirname,
300
+ "..",
301
+ "templates",
302
+ "dashboard.html",
303
+ );
304
+ const template = fs.readFileSync(templatePath, "utf8");
305
+ const jsonData = JSON.stringify(graphData).replace(
306
+ /<\/script/gi,
307
+ "<\\/script",
308
+ );
309
+ return template.replace("__SPRINT_DATA__", jsonData);
265
310
  }
266
311
 
267
312
  /**
268
313
  * Return paths to all claims.json files for watching.
269
314
  */
270
315
  function claimsPaths(targetDir) {
271
- return findSprintFiles(targetDir).map(s => s.file);
316
+ return findSprintFiles(targetDir).map((s) => s.file);
272
317
  }
273
318
 
274
319
  /**
@@ -278,14 +323,22 @@ function generateDashboard(config, root, outPath) {
278
323
  const graphData = loadSprints(root);
279
324
 
280
325
  if (graphData.sprints.length === 0) {
281
- console.error('No sprints found (no claims.json files detected).');
326
+ console.error("No sprints found (no claims.json files detected).");
282
327
  process.exit(1);
283
328
  }
284
329
 
285
330
  const html = buildHtml(graphData);
286
331
  fs.writeFileSync(outPath, html);
287
332
  console.log(`Dashboard written to ${outPath}`);
288
- console.log(` ${graphData.sprints.length} sprints, ${graphData.edges.length} edges, ${graphData.cycles.length} cycles`);
333
+ console.log(
334
+ ` ${graphData.sprints.length} sprints, ${graphData.edges.length} edges, ${graphData.cycles.length} cycles`,
335
+ );
289
336
  }
290
337
 
291
- module.exports = { loadSprints, buildHtml, claimsPaths, findSprintFiles, generateDashboard };
338
+ module.exports = {
339
+ loadSprints,
340
+ buildHtml,
341
+ claimsPaths,
342
+ findSprintFiles,
343
+ generateDashboard,
344
+ };