roadmapsmith 0.4.0 → 0.5.1
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/package.json +40 -41
- package/src/config.js +212 -208
- package/src/generator/index.js +45 -103
- package/src/index.js +10 -7
- package/src/io.js +263 -227
- package/src/match.js +85 -85
- package/src/model.js +32 -33
- package/src/parser/index.js +106 -108
- package/src/renderer/professional.js +11 -2
- package/src/utils.js +141 -142
- package/src/validator/index.js +22 -3
package/src/model.js
CHANGED
|
@@ -1,33 +1,32 @@
|
|
|
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
|
-
checkedById: input.checkedById || {}
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
module.exports = {
|
|
30
|
-
PHASE_ORDER,
|
|
31
|
-
createRoadmapModel
|
|
32
|
-
|
|
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
|
+
checkedById: input.checkedById || {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = {
|
|
30
|
+
PHASE_ORDER,
|
|
31
|
+
createRoadmapModel
|
|
32
|
+
};
|
package/src/parser/index.js
CHANGED
|
@@ -1,109 +1,107 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { slugify } = require('../utils');
|
|
4
|
-
|
|
5
|
-
const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)\s*-->)?\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();
|
|
32
|
-
const markerId = taskMatch[4] || null;
|
|
33
|
-
|
|
34
|
-
let warningLineIndex = null;
|
|
35
|
-
let warningText = null;
|
|
36
|
-
if (index + 1 < lines.length) {
|
|
37
|
-
const nextLine = lines[index + 1];
|
|
38
|
-
const warningMatch = nextLine.match(WARNING_RE);
|
|
39
|
-
if (warningMatch) {
|
|
40
|
-
warningLineIndex = index + 1;
|
|
41
|
-
warningText = warningMatch[1].trim();
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const id = markerId || slugify(text);
|
|
46
|
-
tasks.push({
|
|
47
|
-
id,
|
|
48
|
-
text,
|
|
49
|
-
checked,
|
|
50
|
-
lineIndex: index,
|
|
51
|
-
warningLineIndex,
|
|
52
|
-
warningText,
|
|
53
|
-
markerId,
|
|
54
|
-
indent,
|
|
55
|
-
section
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
lines,
|
|
61
|
-
tasks
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function findManagedRange(lines) {
|
|
66
|
-
let start = -1;
|
|
67
|
-
let end = -1;
|
|
68
|
-
|
|
69
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
70
|
-
if (lines[i].trim() === MANAGED_START) {
|
|
71
|
-
start = i;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (lines[i].trim() === MANAGED_END) {
|
|
75
|
-
end = i;
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (start >= 0 && end >= 0 && start < end) {
|
|
81
|
-
return { start, end };
|
|
82
|
-
}
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function upsertManagedBlock(existingContent, managedBody) {
|
|
87
|
-
const existing = String(existingContent || '');
|
|
88
|
-
const lines = existing.split(/\r?\n/);
|
|
89
|
-
const range = findManagedRange(lines);
|
|
90
|
-
const bodyLines = managedBody.split(/\r?\n/);
|
|
91
|
-
|
|
92
|
-
if (!range) {
|
|
93
|
-
if (existing.trim().length === 0) {
|
|
94
|
-
return [MANAGED_START, ...bodyLines, MANAGED_END].join('\n');
|
|
95
|
-
}
|
|
96
|
-
return `${existing.replace(/\s+$/, '')}\n\n${MANAGED_START}\n${managedBody}\n${MANAGED_END}`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const prefix = lines.slice(0, range.start + 1);
|
|
100
|
-
const suffix = lines.slice(range.end);
|
|
101
|
-
return [...prefix, ...bodyLines, ...suffix].join('\n');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
module.exports = {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
parseRoadmap,
|
|
108
|
-
upsertManagedBlock
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { slugify } = require('../utils');
|
|
4
|
+
|
|
5
|
+
const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)\s*-->)?\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();
|
|
32
|
+
const markerId = taskMatch[4] || null;
|
|
33
|
+
|
|
34
|
+
let warningLineIndex = null;
|
|
35
|
+
let warningText = null;
|
|
36
|
+
if (index + 1 < lines.length) {
|
|
37
|
+
const nextLine = lines[index + 1];
|
|
38
|
+
const warningMatch = nextLine.match(WARNING_RE);
|
|
39
|
+
if (warningMatch) {
|
|
40
|
+
warningLineIndex = index + 1;
|
|
41
|
+
warningText = warningMatch[1].trim();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const id = markerId || slugify(text);
|
|
46
|
+
tasks.push({
|
|
47
|
+
id,
|
|
48
|
+
text,
|
|
49
|
+
checked,
|
|
50
|
+
lineIndex: index,
|
|
51
|
+
warningLineIndex,
|
|
52
|
+
warningText,
|
|
53
|
+
markerId,
|
|
54
|
+
indent,
|
|
55
|
+
section
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
lines,
|
|
61
|
+
tasks
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findManagedRange(lines) {
|
|
66
|
+
let start = -1;
|
|
67
|
+
let end = -1;
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
70
|
+
if (lines[i].trim() === MANAGED_START) {
|
|
71
|
+
start = i;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (lines[i].trim() === MANAGED_END) {
|
|
75
|
+
end = i;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (start >= 0 && end >= 0 && start < end) {
|
|
81
|
+
return { start, end };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function upsertManagedBlock(existingContent, managedBody) {
|
|
87
|
+
const existing = String(existingContent || '');
|
|
88
|
+
const lines = existing.split(/\r?\n/);
|
|
89
|
+
const range = findManagedRange(lines);
|
|
90
|
+
const bodyLines = managedBody.split(/\r?\n/);
|
|
91
|
+
|
|
92
|
+
if (!range) {
|
|
93
|
+
if (existing.trim().length === 0) {
|
|
94
|
+
return [MANAGED_START, ...bodyLines, MANAGED_END].join('\n');
|
|
95
|
+
}
|
|
96
|
+
return `${existing.replace(/\s+$/, '')}\n\n${MANAGED_START}\n${managedBody}\n${MANAGED_END}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const prefix = lines.slice(0, range.start + 1);
|
|
100
|
+
const suffix = lines.slice(range.end);
|
|
101
|
+
return [...prefix, ...bodyLines, ...suffix].join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
parseRoadmap,
|
|
106
|
+
upsertManagedBlock
|
|
109
107
|
};
|
|
@@ -73,6 +73,13 @@ function renderSection3CurrentState(model, lines) {
|
|
|
73
73
|
}
|
|
74
74
|
lines.push('');
|
|
75
75
|
|
|
76
|
+
if (model.currentState.workspaces && model.currentState.workspaces.length > 0) {
|
|
77
|
+
lines.push('### Workspace Packages');
|
|
78
|
+
lines.push('');
|
|
79
|
+
lines.push(`- Workspace packages detected: ${model.currentState.workspaces.join(', ')}`);
|
|
80
|
+
lines.push('');
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
lines.push('### Known Limitations');
|
|
77
84
|
lines.push('');
|
|
78
85
|
if (model.currentState.knownLimitations && model.currentState.knownLimitations.length > 0) {
|
|
@@ -169,8 +176,10 @@ function renderSection5Milestones(model, lines) {
|
|
|
169
176
|
lines.push('**What Must Be Stable:**');
|
|
170
177
|
lines.push('');
|
|
171
178
|
for (const item of milestone.mustBeStable) {
|
|
172
|
-
const
|
|
173
|
-
|
|
179
|
+
const text = typeof item === 'string' ? item : item.text;
|
|
180
|
+
const note = typeof item === 'object' && item.note ? ` — _${item.note}_` : '';
|
|
181
|
+
const id = `prof-ms-${msSlug}-stable-${slugify(text)}`;
|
|
182
|
+
lines.push(`- [${checkedState(model, id) ? 'x' : ' '}] \`[P1]\` ${text}${note} <!-- rs:task=${id} -->`);
|
|
174
183
|
}
|
|
175
184
|
lines.push('');
|
|
176
185
|
}
|
package/src/utils.js
CHANGED
|
@@ -1,143 +1,142 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
const STOP_WORDS = new Set([
|
|
6
|
-
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'that',
|
|
7
|
-
'the', 'to', 'with', 'this', 'these', 'those', 'via', 'per', 'task', 'tasks', 'phase', 'priority'
|
|
8
|
-
]);
|
|
9
|
-
|
|
10
|
-
function toPosix(input) {
|
|
11
|
-
return input.split(path.sep).join('/');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function slugify(text) {
|
|
15
|
-
return String(text || '')
|
|
16
|
-
.toLowerCase()
|
|
17
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
-
.replace(/^-+|-+$/g, '')
|
|
19
|
-
.replace(/-{2,}/g, '-') || 'task';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function normalizeText(text) {
|
|
23
|
-
return String(text || '')
|
|
24
|
-
.toLowerCase()
|
|
25
|
-
.replace(/[`*_~#>\[\](){}.!?,:;"']/g, ' ')
|
|
26
|
-
.replace(/\s+/g, ' ')
|
|
27
|
-
.trim();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function tokenize(text) {
|
|
31
|
-
return normalizeText(text)
|
|
32
|
-
.split(' ')
|
|
33
|
-
.filter(Boolean)
|
|
34
|
-
.filter((token) => !STOP_WORDS.has(token));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function uniqueBy(items, keyFn) {
|
|
38
|
-
const seen = new Set();
|
|
39
|
-
const result = [];
|
|
40
|
-
for (const item of items) {
|
|
41
|
-
const key = keyFn(item);
|
|
42
|
-
if (seen.has(key)) {
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
seen.add(key);
|
|
46
|
-
result.push(item);
|
|
47
|
-
}
|
|
48
|
-
return result;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function similarityScore(left, right) {
|
|
52
|
-
const leftTokens = new Set(tokenize(left));
|
|
53
|
-
const rightTokens = new Set(tokenize(right));
|
|
54
|
-
if (leftTokens.size === 0 || rightTokens.size === 0) {
|
|
55
|
-
return 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
let shared = 0;
|
|
59
|
-
for (const token of leftTokens) {
|
|
60
|
-
if (rightTokens.has(token)) {
|
|
61
|
-
shared += 1;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const union = new Set([...leftTokens, ...rightTokens]);
|
|
66
|
-
return shared / union.size;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function ensureTrailingNewline(text) {
|
|
70
|
-
return text.endsWith('\n') ? text : `${text}\n`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function escapeRegExp(value) {
|
|
74
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function parseArgv(argv) {
|
|
78
|
-
const flags = {};
|
|
79
|
-
const positionals = [];
|
|
80
|
-
|
|
81
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
82
|
-
const current = argv[i];
|
|
83
|
-
if (!current.startsWith('-')) {
|
|
84
|
-
positionals.push(current);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (current.startsWith('--')) {
|
|
89
|
-
const withoutPrefix = current.slice(2);
|
|
90
|
-
const eqIndex = withoutPrefix.indexOf('=');
|
|
91
|
-
let key;
|
|
92
|
-
let value;
|
|
93
|
-
|
|
94
|
-
if (eqIndex >= 0) {
|
|
95
|
-
key = withoutPrefix.slice(0, eqIndex);
|
|
96
|
-
value = withoutPrefix.slice(eqIndex + 1);
|
|
97
|
-
} else {
|
|
98
|
-
key = withoutPrefix;
|
|
99
|
-
const next = argv[i + 1];
|
|
100
|
-
if (next && !next.startsWith('-')) {
|
|
101
|
-
value = next;
|
|
102
|
-
i += 1;
|
|
103
|
-
} else {
|
|
104
|
-
value = true;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (Object.prototype.hasOwnProperty.call(flags, key)) {
|
|
109
|
-
if (Array.isArray(flags[key])) {
|
|
110
|
-
flags[key].push(value);
|
|
111
|
-
} else {
|
|
112
|
-
flags[key] = [flags[key], value];
|
|
113
|
-
}
|
|
114
|
-
} else {
|
|
115
|
-
flags[key] = value;
|
|
116
|
-
}
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const short = current.slice(1);
|
|
121
|
-
flags[short] = true;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const command = positionals.length > 0 ? positionals[0] : null;
|
|
125
|
-
return {
|
|
126
|
-
command,
|
|
127
|
-
args: positionals.slice(1),
|
|
128
|
-
flags,
|
|
129
|
-
positionals
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
module.exports = {
|
|
134
|
-
escapeRegExp,
|
|
135
|
-
ensureTrailingNewline,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
uniqueBy
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const STOP_WORDS = new Set([
|
|
6
|
+
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'that',
|
|
7
|
+
'the', 'to', 'with', 'this', 'these', 'those', 'via', 'per', 'task', 'tasks', 'phase', 'priority'
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function toPosix(input) {
|
|
11
|
+
return input.split(path.sep).join('/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function slugify(text) {
|
|
15
|
+
return String(text || '')
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '')
|
|
19
|
+
.replace(/-{2,}/g, '-') || 'task';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeText(text) {
|
|
23
|
+
return String(text || '')
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[`*_~#>\[\](){}.!?,:;"']/g, ' ')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tokenize(text) {
|
|
31
|
+
return normalizeText(text)
|
|
32
|
+
.split(' ')
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.filter((token) => !STOP_WORDS.has(token));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function uniqueBy(items, keyFn) {
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const result = [];
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
const key = keyFn(item);
|
|
42
|
+
if (seen.has(key)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
seen.add(key);
|
|
46
|
+
result.push(item);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function similarityScore(left, right) {
|
|
52
|
+
const leftTokens = new Set(tokenize(left));
|
|
53
|
+
const rightTokens = new Set(tokenize(right));
|
|
54
|
+
if (leftTokens.size === 0 || rightTokens.size === 0) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let shared = 0;
|
|
59
|
+
for (const token of leftTokens) {
|
|
60
|
+
if (rightTokens.has(token)) {
|
|
61
|
+
shared += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const union = new Set([...leftTokens, ...rightTokens]);
|
|
66
|
+
return shared / union.size;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensureTrailingNewline(text) {
|
|
70
|
+
return text.endsWith('\n') ? text : `${text}\n`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function escapeRegExp(value) {
|
|
74
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseArgv(argv) {
|
|
78
|
+
const flags = {};
|
|
79
|
+
const positionals = [];
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
82
|
+
const current = argv[i];
|
|
83
|
+
if (!current.startsWith('-')) {
|
|
84
|
+
positionals.push(current);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (current.startsWith('--')) {
|
|
89
|
+
const withoutPrefix = current.slice(2);
|
|
90
|
+
const eqIndex = withoutPrefix.indexOf('=');
|
|
91
|
+
let key;
|
|
92
|
+
let value;
|
|
93
|
+
|
|
94
|
+
if (eqIndex >= 0) {
|
|
95
|
+
key = withoutPrefix.slice(0, eqIndex);
|
|
96
|
+
value = withoutPrefix.slice(eqIndex + 1);
|
|
97
|
+
} else {
|
|
98
|
+
key = withoutPrefix;
|
|
99
|
+
const next = argv[i + 1];
|
|
100
|
+
if (next && !next.startsWith('-')) {
|
|
101
|
+
value = next;
|
|
102
|
+
i += 1;
|
|
103
|
+
} else {
|
|
104
|
+
value = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (Object.prototype.hasOwnProperty.call(flags, key)) {
|
|
109
|
+
if (Array.isArray(flags[key])) {
|
|
110
|
+
flags[key].push(value);
|
|
111
|
+
} else {
|
|
112
|
+
flags[key] = [flags[key], value];
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
flags[key] = value;
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const short = current.slice(1);
|
|
121
|
+
flags[short] = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const command = positionals.length > 0 ? positionals[0] : null;
|
|
125
|
+
return {
|
|
126
|
+
command,
|
|
127
|
+
args: positionals.slice(1),
|
|
128
|
+
flags,
|
|
129
|
+
positionals
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
escapeRegExp,
|
|
135
|
+
ensureTrailingNewline,
|
|
136
|
+
parseArgv,
|
|
137
|
+
similarityScore,
|
|
138
|
+
slugify,
|
|
139
|
+
toPosix,
|
|
140
|
+
tokenize,
|
|
141
|
+
uniqueBy
|
|
143
142
|
};
|
package/src/validator/index.js
CHANGED
|
@@ -56,6 +56,24 @@ function readFileIndex(projectRoot, files) {
|
|
|
56
56
|
return index;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
const KNOWN_PATH_ROOTS = [
|
|
60
|
+
'src/', 'lib/', 'bin/', 'test/', 'tests/', 'docs/', 'scripts/',
|
|
61
|
+
'packages/', 'apps/', 'tools/', '.github/', 'roadmap-skill/'
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function hasFileExtension(token) {
|
|
65
|
+
const lastSegment = token.replace(/\\/g, '/').split('/').pop() || '';
|
|
66
|
+
return /\.[A-Za-z0-9]{1,10}$/.test(lastSegment);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isLikelyPath(token) {
|
|
70
|
+
if (/^\.{1,2}\/|^\//.test(token)) return true;
|
|
71
|
+
if (hasFileExtension(token)) return true;
|
|
72
|
+
if (KNOWN_PATH_ROOTS.some((root) => token.startsWith(root))) return true;
|
|
73
|
+
if ((token.match(/\//g) || []).length >= 2) return true;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
function extractExplicitPaths(text) {
|
|
60
78
|
const results = new Set();
|
|
61
79
|
const quoted = String(text).match(/`([^`]+)`/g) || [];
|
|
@@ -67,8 +85,9 @@ function extractExplicitPaths(text) {
|
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
const pathTokens = String(text).match(/([A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+)/g) || [];
|
|
70
|
-
for (const
|
|
71
|
-
|
|
88
|
+
for (const raw of pathTokens) {
|
|
89
|
+
const token = raw.replace(/[.,;:!?)]+$/, '');
|
|
90
|
+
if (isLikelyPath(token)) results.add(token);
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
return Array.from(results).sort((left, right) => left.localeCompare(right));
|
|
@@ -398,4 +417,4 @@ module.exports = {
|
|
|
398
417
|
buildValidationContext,
|
|
399
418
|
validateTask,
|
|
400
419
|
validateTasks
|
|
401
|
-
};
|
|
420
|
+
};
|