@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/CONTRIBUTING.md +6 -0
- package/README.md +21 -20
- package/bin/orchard.js +227 -80
- package/lib/assignments.js +19 -17
- package/lib/conflicts.js +177 -29
- package/lib/dashboard.js +100 -47
- package/lib/decompose.js +268 -0
- package/lib/doctor.js +48 -32
- package/lib/export.js +72 -44
- package/lib/farmer.js +54 -38
- package/lib/hackathon.js +349 -0
- package/lib/planner.js +150 -21
- package/lib/server.js +395 -165
- package/lib/sync.js +31 -25
- package/lib/tracker.js +52 -40
- package/package.json +7 -3
package/lib/conflicts.js
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
|
-
const fs = require(
|
|
4
|
-
const path = require(
|
|
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)
|
|
11
|
-
|
|
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,
|
|
17
|
-
const claims = Array.isArray(data) ? data :
|
|
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 ===
|
|
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:
|
|
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 ===
|
|
79
|
-
(a.type ===
|
|
90
|
+
(a.type === "constraint" && b.type === "recommendation") ||
|
|
91
|
+
(a.type === "recommendation" && b.type === "constraint")
|
|
80
92
|
) {
|
|
81
93
|
conflicts.push({
|
|
82
|
-
type:
|
|
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 = [
|
|
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
|
|
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(
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
"use strict";
|
|
2
2
|
|
|
3
|
-
const fs = require(
|
|
4
|
-
const path = require(
|
|
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,
|
|
14
|
+
const direct = path.join(targetDir, "claims.json");
|
|
15
15
|
if (fs.existsSync(direct)) {
|
|
16
|
-
found.push({
|
|
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,
|
|
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(
|
|
24
|
-
found.push({
|
|
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 (
|
|
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,
|
|
51
|
+
const childClaims = path.join(childDir, "claims.json");
|
|
37
52
|
if (fs.existsSync(childClaims)) {
|
|
38
|
-
found.push({
|
|
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(
|
|
65
|
+
if (sub.name.startsWith(".")) continue;
|
|
46
66
|
const subDir = path.join(childDir, sub.name);
|
|
47
|
-
const subClaims = path.join(subDir,
|
|
67
|
+
const subClaims = path.join(subDir, "claims.json");
|
|
48
68
|
if (fs.existsSync(subClaims)) {
|
|
49
|
-
found.push({
|
|
69
|
+
found.push({
|
|
70
|
+
file: subClaims,
|
|
71
|
+
dir: subDir,
|
|
72
|
+
name: sub.name,
|
|
73
|
+
cat: "active",
|
|
74
|
+
});
|
|
50
75
|
}
|
|
51
76
|
}
|
|
52
|
-
} catch {
|
|
77
|
+
} catch {
|
|
78
|
+
/* skip */
|
|
79
|
+
}
|
|
53
80
|
}
|
|
54
|
-
} catch {
|
|
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,
|
|
93
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
65
94
|
const meta = raw.meta || {};
|
|
66
|
-
const claims = Array.isArray(raw) ? raw :
|
|
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 ||
|
|
96
|
-
phase: meta.phase ||
|
|
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 ===
|
|
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,
|
|
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 !==
|
|
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,
|
|
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 ===
|
|
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,
|
|
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,
|
|
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 ||
|
|
160
|
-
if (st ===
|
|
161
|
-
else if (st ===
|
|
162
|
-
else if (st ===
|
|
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)
|
|
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(
|
|
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,
|
|
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
|
|
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(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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(
|
|
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(
|
|
333
|
+
console.log(
|
|
334
|
+
` ${graphData.sprints.length} sprints, ${graphData.edges.length} edges, ${graphData.cycles.length} cycles`,
|
|
335
|
+
);
|
|
289
336
|
}
|
|
290
337
|
|
|
291
|
-
module.exports = {
|
|
338
|
+
module.exports = {
|
|
339
|
+
loadSprints,
|
|
340
|
+
buildHtml,
|
|
341
|
+
claimsPaths,
|
|
342
|
+
findSprintFiles,
|
|
343
|
+
generateDashboard,
|
|
344
|
+
};
|