roadmapsmith 0.9.3 → 0.9.5
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/README.md +20 -0
- package/bin/cli.js +254 -254
- package/package.json +56 -56
- package/src/config.js +219 -219
- package/src/generator/index.js +614 -614
- package/src/index.js +11 -11
- package/src/io.js +264 -264
- package/src/match.js +86 -86
- package/src/model.js +33 -33
- package/src/parser/index.js +100 -100
- package/src/renderer/professional.js +544 -544
- package/src/sync/index.js +1 -1
- package/src/templates/index.js +1 -1
- package/src/utils.js +142 -142
- package/src/validator/index.js +727 -641
- package/templates/roadmap.template.md +1 -1
package/src/match.js
CHANGED
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { similarityScore, slugify, tokenize, uniqueBy } = require('./utils');
|
|
4
|
-
const { PHASE_ORDER } = require('./model');
|
|
5
|
-
|
|
6
|
-
function canonicalSignature(text) {
|
|
7
|
-
const tokens = uniqueBy(tokenize(text), (token) => token).slice(0, 8);
|
|
8
|
-
return tokens.join('-') || slugify(text);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function inferPriorityWeight(priority) {
|
|
12
|
-
const normalized = String(priority || '').toUpperCase();
|
|
13
|
-
if (normalized === 'P0') return 0;
|
|
14
|
-
if (normalized === 'P1') return 1;
|
|
15
|
-
if (normalized === 'P2') return 2;
|
|
16
|
-
if (normalized === 'P3') return 3;
|
|
17
|
-
return 4;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function findBestTaskMatch(candidate, existingTasks, minScore = 0.55) {
|
|
21
|
-
const direct = existingTasks.find((task) => task.id === candidate.id);
|
|
22
|
-
if (direct) {
|
|
23
|
-
return { task: direct, score: 1 };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
let best = null;
|
|
27
|
-
for (const task of existingTasks) {
|
|
28
|
-
const score = similarityScore(candidate.text, task.text);
|
|
29
|
-
if (score < minScore) {
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
if (!best || score > best.score) {
|
|
33
|
-
best = { task, score };
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return best;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function dedupeTasks(tasks) {
|
|
41
|
-
const grouped = new Map();
|
|
42
|
-
|
|
43
|
-
for (const task of tasks) {
|
|
44
|
-
const signature = canonicalSignature(task.text);
|
|
45
|
-
if (!grouped.has(signature)) {
|
|
46
|
-
grouped.set(signature, task);
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const current = grouped.get(signature);
|
|
51
|
-
const candidate = task;
|
|
52
|
-
|
|
53
|
-
if (current.checked !== candidate.checked) {
|
|
54
|
-
grouped.set(signature, candidate.checked ? candidate : current);
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const currentWeight = inferPriorityWeight(current.priority);
|
|
59
|
-
const candidateWeight = inferPriorityWeight(candidate.priority);
|
|
60
|
-
if (candidateWeight < currentWeight) {
|
|
61
|
-
grouped.set(signature, candidate);
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (candidateWeight === currentWeight && candidate.text.length < current.text.length) {
|
|
66
|
-
grouped.set(signature, candidate);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return Array.from(grouped.values()).sort((left, right) => {
|
|
71
|
-
const leftPhase = PHASE_ORDER.indexOf(left.phase);
|
|
72
|
-
const rightPhase = PHASE_ORDER.indexOf(right.phase);
|
|
73
|
-
if (leftPhase !== rightPhase) {
|
|
74
|
-
return leftPhase - rightPhase;
|
|
75
|
-
}
|
|
76
|
-
if (left.priority !== right.priority) {
|
|
77
|
-
return inferPriorityWeight(left.priority) - inferPriorityWeight(right.priority);
|
|
78
|
-
}
|
|
79
|
-
return left.text.localeCompare(right.text);
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
module.exports = {
|
|
84
|
-
dedupeTasks,
|
|
85
|
-
findBestTaskMatch
|
|
86
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { similarityScore, slugify, tokenize, uniqueBy } = require('./utils');
|
|
4
|
+
const { PHASE_ORDER } = require('./model');
|
|
5
|
+
|
|
6
|
+
function canonicalSignature(text) {
|
|
7
|
+
const tokens = uniqueBy(tokenize(text), (token) => token).slice(0, 8);
|
|
8
|
+
return tokens.join('-') || slugify(text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function inferPriorityWeight(priority) {
|
|
12
|
+
const normalized = String(priority || '').toUpperCase();
|
|
13
|
+
if (normalized === 'P0') return 0;
|
|
14
|
+
if (normalized === 'P1') return 1;
|
|
15
|
+
if (normalized === 'P2') return 2;
|
|
16
|
+
if (normalized === 'P3') return 3;
|
|
17
|
+
return 4;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findBestTaskMatch(candidate, existingTasks, minScore = 0.55) {
|
|
21
|
+
const direct = existingTasks.find((task) => task.id === candidate.id);
|
|
22
|
+
if (direct) {
|
|
23
|
+
return { task: direct, score: 1 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let best = null;
|
|
27
|
+
for (const task of existingTasks) {
|
|
28
|
+
const score = similarityScore(candidate.text, task.text);
|
|
29
|
+
if (score < minScore) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!best || score > best.score) {
|
|
33
|
+
best = { task, score };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return best;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dedupeTasks(tasks) {
|
|
41
|
+
const grouped = new Map();
|
|
42
|
+
|
|
43
|
+
for (const task of tasks) {
|
|
44
|
+
const signature = canonicalSignature(task.text);
|
|
45
|
+
if (!grouped.has(signature)) {
|
|
46
|
+
grouped.set(signature, task);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const current = grouped.get(signature);
|
|
51
|
+
const candidate = task;
|
|
52
|
+
|
|
53
|
+
if (current.checked !== candidate.checked) {
|
|
54
|
+
grouped.set(signature, candidate.checked ? candidate : current);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const currentWeight = inferPriorityWeight(current.priority);
|
|
59
|
+
const candidateWeight = inferPriorityWeight(candidate.priority);
|
|
60
|
+
if (candidateWeight < currentWeight) {
|
|
61
|
+
grouped.set(signature, candidate);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (candidateWeight === currentWeight && candidate.text.length < current.text.length) {
|
|
66
|
+
grouped.set(signature, candidate);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return Array.from(grouped.values()).sort((left, right) => {
|
|
71
|
+
const leftPhase = PHASE_ORDER.indexOf(left.phase);
|
|
72
|
+
const rightPhase = PHASE_ORDER.indexOf(right.phase);
|
|
73
|
+
if (leftPhase !== rightPhase) {
|
|
74
|
+
return leftPhase - rightPhase;
|
|
75
|
+
}
|
|
76
|
+
if (left.priority !== right.priority) {
|
|
77
|
+
return inferPriorityWeight(left.priority) - inferPriorityWeight(right.priority);
|
|
78
|
+
}
|
|
79
|
+
return left.text.localeCompare(right.text);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
dedupeTasks,
|
|
85
|
+
findBestTaskMatch
|
|
86
|
+
};
|
package/src/model.js
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const PHASE_ORDER = ['P0', 'P1', 'P2'];
|
|
4
|
-
|
|
5
|
-
function phaseWeight(phase) {
|
|
6
|
-
const idx = PHASE_ORDER.indexOf(phase);
|
|
7
|
-
return idx >= 0 ? idx : PHASE_ORDER.length;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function createRoadmapModel(input) {
|
|
11
|
-
return {
|
|
12
|
-
northStar: input.northStar,
|
|
13
|
-
product: input.product || {},
|
|
14
|
-
currentState: input.currentState,
|
|
15
|
-
phases: input.phases,
|
|
16
|
-
steps: input.steps || [],
|
|
17
|
-
phasesDetailed: input.phasesDetailed || [],
|
|
18
|
-
milestones: input.milestones,
|
|
19
|
-
commandBreakdown: input.commandBreakdown,
|
|
20
|
-
exitCriteria: input.exitCriteria,
|
|
21
|
-
risks: input.risks,
|
|
22
|
-
antiGoals: input.antiGoals,
|
|
23
|
-
successCriteria: input.successCriteria || [],
|
|
24
|
-
customSections: input.customSections || [],
|
|
25
|
-
customPhases: input.customPhases || [],
|
|
26
|
-
checkedById: input.checkedById || {}
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
module.exports = {
|
|
31
|
-
PHASE_ORDER,
|
|
32
|
-
createRoadmapModel
|
|
33
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PHASE_ORDER = ['P0', 'P1', 'P2'];
|
|
4
|
+
|
|
5
|
+
function phaseWeight(phase) {
|
|
6
|
+
const idx = PHASE_ORDER.indexOf(phase);
|
|
7
|
+
return idx >= 0 ? idx : PHASE_ORDER.length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function createRoadmapModel(input) {
|
|
11
|
+
return {
|
|
12
|
+
northStar: input.northStar,
|
|
13
|
+
product: input.product || {},
|
|
14
|
+
currentState: input.currentState,
|
|
15
|
+
phases: input.phases,
|
|
16
|
+
steps: input.steps || [],
|
|
17
|
+
phasesDetailed: input.phasesDetailed || [],
|
|
18
|
+
milestones: input.milestones,
|
|
19
|
+
commandBreakdown: input.commandBreakdown,
|
|
20
|
+
exitCriteria: input.exitCriteria,
|
|
21
|
+
risks: input.risks,
|
|
22
|
+
antiGoals: input.antiGoals,
|
|
23
|
+
successCriteria: input.successCriteria || [],
|
|
24
|
+
customSections: input.customSections || [],
|
|
25
|
+
customPhases: input.customPhases || [],
|
|
26
|
+
checkedById: input.checkedById || {}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
PHASE_ORDER,
|
|
32
|
+
createRoadmapModel
|
|
33
|
+
};
|
package/src/parser/index.js
CHANGED
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { slugify } = require('../utils');
|
|
4
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { slugify } = require('../utils');
|
|
4
|
+
|
|
5
5
|
const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)([^>]*)-->)?\s*$/;
|
|
6
|
-
const WARNING_RE = /^\s*-\s+⚠️ attempted but validation failed:\s*(.+?)\s*$/;
|
|
7
|
-
const HEADING_RE = /^#{2,3}\s+(.*)$/;
|
|
8
|
-
|
|
9
|
-
const MANAGED_START = '<!-- rs:managed:start -->';
|
|
10
|
-
const MANAGED_END = '<!-- rs:managed:end -->';
|
|
11
|
-
|
|
12
|
-
function parseRoadmap(content) {
|
|
13
|
-
const lines = String(content || '').split(/\r?\n/);
|
|
14
|
-
const tasks = [];
|
|
15
|
-
let section = '';
|
|
16
|
-
|
|
17
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
18
|
-
const line = lines[index];
|
|
19
|
-
const headingMatch = line.match(HEADING_RE);
|
|
20
|
-
if (headingMatch) {
|
|
21
|
-
section = headingMatch[1].trim();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const taskMatch = line.match(TASK_LINE_RE);
|
|
25
|
-
if (!taskMatch) {
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const indent = taskMatch[1] || '';
|
|
30
|
-
const checked = taskMatch[2].toLowerCase() === 'x';
|
|
31
|
-
const text = taskMatch[3].trim();
|
|
6
|
+
const WARNING_RE = /^\s*-\s+⚠️ attempted but validation failed:\s*(.+?)\s*$/;
|
|
7
|
+
const HEADING_RE = /^#{2,3}\s+(.*)$/;
|
|
8
|
+
|
|
9
|
+
const MANAGED_START = '<!-- rs:managed:start -->';
|
|
10
|
+
const MANAGED_END = '<!-- rs:managed:end -->';
|
|
11
|
+
|
|
12
|
+
function parseRoadmap(content) {
|
|
13
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
14
|
+
const tasks = [];
|
|
15
|
+
let section = '';
|
|
16
|
+
|
|
17
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
18
|
+
const line = lines[index];
|
|
19
|
+
const headingMatch = line.match(HEADING_RE);
|
|
20
|
+
if (headingMatch) {
|
|
21
|
+
section = headingMatch[1].trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const taskMatch = line.match(TASK_LINE_RE);
|
|
25
|
+
if (!taskMatch) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const indent = taskMatch[1] || '';
|
|
30
|
+
const checked = taskMatch[2].toLowerCase() === 'x';
|
|
31
|
+
const text = taskMatch[3].trim();
|
|
32
32
|
const markerId = taskMatch[4] || null;
|
|
33
33
|
const markerFlags = taskMatch[5] || '';
|
|
34
34
|
const noTest = /\brs:no-test\b/i.test(markerFlags);
|
|
35
|
-
|
|
36
|
-
let warningLineIndex = null;
|
|
37
|
-
let warningText = null;
|
|
38
|
-
if (index + 1 < lines.length) {
|
|
39
|
-
const nextLine = lines[index + 1];
|
|
40
|
-
const warningMatch = nextLine.match(WARNING_RE);
|
|
41
|
-
if (warningMatch) {
|
|
42
|
-
warningLineIndex = index + 1;
|
|
43
|
-
warningText = warningMatch[1].trim();
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const id = markerId || slugify(text);
|
|
48
|
-
tasks.push({
|
|
49
|
-
id,
|
|
50
|
-
text,
|
|
51
|
-
checked,
|
|
52
|
-
lineIndex: index,
|
|
53
|
-
warningLineIndex,
|
|
35
|
+
|
|
36
|
+
let warningLineIndex = null;
|
|
37
|
+
let warningText = null;
|
|
38
|
+
if (index + 1 < lines.length) {
|
|
39
|
+
const nextLine = lines[index + 1];
|
|
40
|
+
const warningMatch = nextLine.match(WARNING_RE);
|
|
41
|
+
if (warningMatch) {
|
|
42
|
+
warningLineIndex = index + 1;
|
|
43
|
+
warningText = warningMatch[1].trim();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const id = markerId || slugify(text);
|
|
48
|
+
tasks.push({
|
|
49
|
+
id,
|
|
50
|
+
text,
|
|
51
|
+
checked,
|
|
52
|
+
lineIndex: index,
|
|
53
|
+
warningLineIndex,
|
|
54
54
|
warningText,
|
|
55
55
|
markerId,
|
|
56
56
|
noTest,
|
|
57
57
|
indent,
|
|
58
58
|
section
|
|
59
59
|
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
lines,
|
|
64
|
-
tasks
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function findManagedRange(lines) {
|
|
69
|
-
let start = -1;
|
|
70
|
-
let end = -1;
|
|
71
|
-
|
|
72
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
73
|
-
if (lines[i].trim() === MANAGED_START) {
|
|
74
|
-
start = i;
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
if (lines[i].trim() === MANAGED_END) {
|
|
78
|
-
end = i;
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (start >= 0 && end >= 0 && start < end) {
|
|
84
|
-
return { start, end };
|
|
85
|
-
}
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function upsertManagedBlock(existingContent, managedBody) {
|
|
90
|
-
const existing = String(existingContent || '');
|
|
91
|
-
const lines = existing.split(/\r?\n/);
|
|
92
|
-
const range = findManagedRange(lines);
|
|
93
|
-
const bodyLines = managedBody.split(/\r?\n/);
|
|
94
|
-
|
|
95
|
-
if (!range) {
|
|
96
|
-
if (existing.trim().length === 0) {
|
|
97
|
-
return [MANAGED_START, ...bodyLines, MANAGED_END].join('\n');
|
|
98
|
-
}
|
|
99
|
-
return `${existing.replace(/\s+$/, '')}\n\n${MANAGED_START}\n${managedBody}\n${MANAGED_END}`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const prefix = lines.slice(0, range.start + 1);
|
|
103
|
-
const suffix = lines.slice(range.end);
|
|
104
|
-
return [...prefix, ...bodyLines, ...suffix].join('\n');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
module.exports = {
|
|
108
|
-
parseRoadmap,
|
|
109
|
-
upsertManagedBlock
|
|
110
|
-
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
lines,
|
|
64
|
+
tasks
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findManagedRange(lines) {
|
|
69
|
+
let start = -1;
|
|
70
|
+
let end = -1;
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
73
|
+
if (lines[i].trim() === MANAGED_START) {
|
|
74
|
+
start = i;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (lines[i].trim() === MANAGED_END) {
|
|
78
|
+
end = i;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (start >= 0 && end >= 0 && start < end) {
|
|
84
|
+
return { start, end };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function upsertManagedBlock(existingContent, managedBody) {
|
|
90
|
+
const existing = String(existingContent || '');
|
|
91
|
+
const lines = existing.split(/\r?\n/);
|
|
92
|
+
const range = findManagedRange(lines);
|
|
93
|
+
const bodyLines = managedBody.split(/\r?\n/);
|
|
94
|
+
|
|
95
|
+
if (!range) {
|
|
96
|
+
if (existing.trim().length === 0) {
|
|
97
|
+
return [MANAGED_START, ...bodyLines, MANAGED_END].join('\n');
|
|
98
|
+
}
|
|
99
|
+
return `${existing.replace(/\s+$/, '')}\n\n${MANAGED_START}\n${managedBody}\n${MANAGED_END}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const prefix = lines.slice(0, range.start + 1);
|
|
103
|
+
const suffix = lines.slice(range.end);
|
|
104
|
+
return [...prefix, ...bodyLines, ...suffix].join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
parseRoadmap,
|
|
109
|
+
upsertManagedBlock
|
|
110
|
+
};
|