roadmapsmith 0.9.2 → 0.9.3
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 +5 -1
- 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 +104 -101
- 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 +775 -624
- package/templates/roadmap.template.md +1 -1
package/src/generator/index.js
CHANGED
|
@@ -1,614 +1,614 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { walkFiles, detectLanguages, detectTestFrameworks, detectWorkspaces } = require('../io');
|
|
6
|
-
const { createRoadmapModel, PHASE_ORDER } = require('../model');
|
|
7
|
-
const { slugify, ensureTrailingNewline } = require('../utils');
|
|
8
|
-
const { parseRoadmap, upsertManagedBlock } = require('../parser');
|
|
9
|
-
const { findBestTaskMatch, dedupeTasks } = require('../match');
|
|
10
|
-
const { collectPluginContributions } = require('../config');
|
|
11
|
-
const { renderBody } = require('../renderer');
|
|
12
|
-
const { classifyProject } = require('../classifier');
|
|
13
|
-
|
|
14
|
-
const IMPL_PATTERN_RE = /[/|]TODO|TODO[|/]|[/|]FIXME|FIXME[|/]/;
|
|
15
|
-
const COMMENT_TODO_RE = /(?:\/\/|#|\*\s*).*\b(?:TODO|FIXME)\b/;
|
|
16
|
-
|
|
17
|
-
function isTodoMarker(line) {
|
|
18
|
-
return COMMENT_TODO_RE.test(line) && !IMPL_PATTERN_RE.test(line);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const GENERIC_MODULE_NAMES = new Set(['index', 'main', 'utils', 'common', 'helpers', 'types', 'constants', 'model']);
|
|
22
|
-
|
|
23
|
-
function detectModules(files) {
|
|
24
|
-
const modules = new Set();
|
|
25
|
-
const rootPrefixes = ['src/', 'apps/', 'packages/', 'lib/', 'cmd/', 'internal/'];
|
|
26
|
-
|
|
27
|
-
for (const file of files) {
|
|
28
|
-
let relative;
|
|
29
|
-
|
|
30
|
-
const directRoot = rootPrefixes.find((r) => file.startsWith(r));
|
|
31
|
-
if (directRoot) {
|
|
32
|
-
relative = file.slice(directRoot.length);
|
|
33
|
-
} else {
|
|
34
|
-
let found = false;
|
|
35
|
-
for (const r of rootPrefixes) {
|
|
36
|
-
const idx = file.indexOf('/' + r);
|
|
37
|
-
if (idx !== -1) {
|
|
38
|
-
// Only accept nested prefix when it appears within the first two path segments
|
|
39
|
-
// (e.g. "wrapper/src/..." is fine; "a/b/c/src/..." is too deep and likely a fixture or dependency)
|
|
40
|
-
if (file.slice(0, idx).split('/').length > 2) continue;
|
|
41
|
-
relative = file.slice(idx + 1 + r.length);
|
|
42
|
-
found = true;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
if (!found) continue;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const first = relative.split('/')[0];
|
|
50
|
-
if (!first) continue;
|
|
51
|
-
|
|
52
|
-
if (first.includes('.')) {
|
|
53
|
-
const name = first.slice(0, first.lastIndexOf('.'));
|
|
54
|
-
if (name && !GENERIC_MODULE_NAMES.has(name)) {
|
|
55
|
-
modules.add(name);
|
|
56
|
-
}
|
|
57
|
-
} else {
|
|
58
|
-
modules.add(first);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return Array.from(modules).sort((left, right) => left.localeCompare(right));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function detectCommands(files) {
|
|
66
|
-
const commands = new Set();
|
|
67
|
-
for (const file of files) {
|
|
68
|
-
if (file.startsWith('bin/')) {
|
|
69
|
-
commands.add(path.basename(file, path.extname(file)));
|
|
70
|
-
}
|
|
71
|
-
if (file.startsWith('cmd/')) {
|
|
72
|
-
commands.add(file.split('/')[1] || file);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return Array.from(commands).sort((left, right) => left.localeCompare(right));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function collectTodoHints(projectRoot, files) {
|
|
79
|
-
const hints = [];
|
|
80
|
-
const relevant = files.filter((file) => /\.(js|ts|tsx|py|go|rs|md)$/.test(file)).slice(0, 120);
|
|
81
|
-
|
|
82
|
-
for (const file of relevant) {
|
|
83
|
-
const absolutePath = path.resolve(projectRoot, file);
|
|
84
|
-
let content = '';
|
|
85
|
-
try {
|
|
86
|
-
content = fs.readFileSync(absolutePath, 'utf8');
|
|
87
|
-
} catch {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const lines = content.split(/\r?\n/);
|
|
92
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
93
|
-
if (isTodoMarker(lines[i])) {
|
|
94
|
-
hints.push({
|
|
95
|
-
file,
|
|
96
|
-
line: i + 1,
|
|
97
|
-
text: lines[i].trim()
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
if (hints.length >= 12) {
|
|
101
|
-
return hints;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return hints;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function collectCodeTodoHints(projectRoot, files) {
|
|
110
|
-
const hints = [];
|
|
111
|
-
const codeFiles = files.filter((file) => /\.(js|ts|tsx|py|go|rs)$/.test(file)).slice(0, 120);
|
|
112
|
-
|
|
113
|
-
for (const file of codeFiles) {
|
|
114
|
-
const absolutePath = path.resolve(projectRoot, file);
|
|
115
|
-
let content = '';
|
|
116
|
-
try {
|
|
117
|
-
content = fs.readFileSync(absolutePath, 'utf8');
|
|
118
|
-
} catch {
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const lines = content.split(/\r?\n/);
|
|
123
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
124
|
-
if (isTodoMarker(lines[i])) {
|
|
125
|
-
hints.push({
|
|
126
|
-
file,
|
|
127
|
-
line: i + 1,
|
|
128
|
-
text: lines[i].trim()
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
if (hints.length >= 6) {
|
|
132
|
-
return hints;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return hints;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function scanProject(projectRoot) {
|
|
141
|
-
const files = walkFiles(projectRoot);
|
|
142
|
-
const languages = detectLanguages(files);
|
|
143
|
-
const testFrameworks = detectTestFrameworks(projectRoot, files);
|
|
144
|
-
const modules = detectModules(files);
|
|
145
|
-
const commands = detectCommands(files);
|
|
146
|
-
const todos = collectTodoHints(projectRoot, files);
|
|
147
|
-
const codeTodos = collectCodeTodoHints(projectRoot, files);
|
|
148
|
-
const workspaces = detectWorkspaces(projectRoot, files);
|
|
149
|
-
|
|
150
|
-
const implementedFiles = files.filter((file) => /\.(js|ts|tsx|py|go|rs|java|kt|cs)$/.test(file));
|
|
151
|
-
const testFiles = files.filter((file) => /(^|\/)(__tests__|tests)\//.test(file) || /\.test\.|\.spec\.|_test\.go$/.test(file));
|
|
152
|
-
|
|
153
|
-
const classifier = classifyProject({ projectRoot, files });
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
projectRoot,
|
|
157
|
-
files,
|
|
158
|
-
languages,
|
|
159
|
-
testFrameworks,
|
|
160
|
-
modules,
|
|
161
|
-
commands,
|
|
162
|
-
todos,
|
|
163
|
-
codeTodos,
|
|
164
|
-
workspaces,
|
|
165
|
-
implementedCount: implementedFiles.length,
|
|
166
|
-
testCount: testFiles.length,
|
|
167
|
-
projectType: classifier.type,
|
|
168
|
-
classifierConfidence: classifier.confidence,
|
|
169
|
-
classifierSignals: classifier.signals
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function toCandidate(text, phase, priority, source = 'default') {
|
|
174
|
-
return {
|
|
175
|
-
id: slugify(`${phase}-${text}`),
|
|
176
|
-
text,
|
|
177
|
-
phase,
|
|
178
|
-
priority,
|
|
179
|
-
checked: false,
|
|
180
|
-
source
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const WEB_CANDIDATES_COMMON = [
|
|
185
|
-
{ text: 'Add SEO metadata: title, description, and canonical URL for all pages', phase: 'P0' },
|
|
186
|
-
{ text: 'Implement responsive and mobile-first layout across all breakpoints', phase: 'P0' },
|
|
187
|
-
{ text: 'Establish accessibility baseline (semantic HTML, ARIA labels, keyboard navigation)', phase: 'P0' },
|
|
188
|
-
{ text: 'Add OpenGraph and Twitter card metadata for social sharing', phase: 'P1' },
|
|
189
|
-
{ text: 'Achieve Lighthouse performance score ≥ 90 and resolve critical findings', phase: 'P1' },
|
|
190
|
-
{ text: 'Validate branding consistency: typography, color tokens, and logo usage', phase: 'P1' },
|
|
191
|
-
{ text: 'Configure deployment and hosting pipeline (CI/CD to production)', phase: 'P2' },
|
|
192
|
-
{ text: 'Add web security headers: Content-Security-Policy, X-Frame-Options, HSTS', phase: 'P2' }
|
|
193
|
-
];
|
|
194
|
-
|
|
195
|
-
const LANDING_CANDIDATES = [
|
|
196
|
-
{ text: 'Complete services and content sections with clear value proposition', phase: 'P1' },
|
|
197
|
-
{ text: 'Implement contact form and conversion flow with input validation', phase: 'P1' },
|
|
198
|
-
{ text: 'Set up analytics and conversion event tracking', phase: 'P2' }
|
|
199
|
-
];
|
|
200
|
-
|
|
201
|
-
function buildWebCandidates(scan) {
|
|
202
|
-
const candidates = WEB_CANDIDATES_COMMON.map(({ text, phase }) =>
|
|
203
|
-
toCandidate(text, phase, phase, 'classifier')
|
|
204
|
-
);
|
|
205
|
-
if (scan.projectType === 'landing-site') {
|
|
206
|
-
for (const { text, phase } of LANDING_CANDIDATES) {
|
|
207
|
-
candidates.push(toCandidate(text, phase, phase, 'classifier'));
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return candidates;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function buildDefaultCandidates(scan, config) {
|
|
214
|
-
const languageLabel = scan.languages.length > 0 ? scan.languages.join(', ') : 'current stack';
|
|
215
|
-
const candidates = [];
|
|
216
|
-
|
|
217
|
-
const p0 = [
|
|
218
|
-
...config.phaseTemplates.P0,
|
|
219
|
-
`Document measurable north star metrics for ${languageLabel}`,
|
|
220
|
-
'Close critical TODO and FIXME items blocking release confidence'
|
|
221
|
-
];
|
|
222
|
-
|
|
223
|
-
if (scan.testFrameworks.length === 0) {
|
|
224
|
-
p0.push(`Add automated test harness for ${languageLabel}`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
for (const item of p0) {
|
|
228
|
-
candidates.push(toCandidate(item, 'P0', 'P0'));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const p1 = [
|
|
232
|
-
...config.phaseTemplates.P1,
|
|
233
|
-
'Expand feature-level validation and regression checks'
|
|
234
|
-
];
|
|
235
|
-
|
|
236
|
-
for (const moduleName of scan.modules.slice(0, 5)) {
|
|
237
|
-
p1.push(`Finalize module implementation: ${moduleName}`);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
for (const commandName of scan.commands.slice(0, 5)) {
|
|
241
|
-
p1.push(`Harden command behavior and error handling: ${commandName}`);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
for (const item of p1) {
|
|
245
|
-
candidates.push(toCandidate(item, 'P1', 'P1'));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const p2 = [
|
|
249
|
-
...config.phaseTemplates.P2,
|
|
250
|
-
'Complete release candidate checklist and production readiness review'
|
|
251
|
-
];
|
|
252
|
-
|
|
253
|
-
for (const item of p2) {
|
|
254
|
-
candidates.push(toCandidate(item, 'P2', 'P2'));
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
for (const hint of scan.todos.slice(0, 5)) {
|
|
258
|
-
candidates.push(toCandidate(`Resolve backlog note in ${hint.file}`, 'P0', 'P0', 'todo-hint'));
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (scan.projectType === 'frontend-web' || scan.projectType === 'landing-site') {
|
|
262
|
-
candidates.push(...buildWebCandidates(scan));
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return candidates;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function applyTaskMatchers(scan, config) {
|
|
269
|
-
const candidates = [];
|
|
270
|
-
for (const matcher of config.taskMatchers || []) {
|
|
271
|
-
if (!matcher || !matcher.pattern || !matcher.task) {
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const regex = new RegExp(matcher.pattern, 'i');
|
|
276
|
-
if (!scan.files.some((file) => regex.test(file))) {
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const phase = matcher.phase || matcher.priority || 'P1';
|
|
281
|
-
const priority = matcher.priority || phase;
|
|
282
|
-
candidates.push(toCandidate(matcher.task, phase, priority, 'task-matcher'));
|
|
283
|
-
}
|
|
284
|
-
return candidates;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function inferPhase(existingTask) {
|
|
288
|
-
const section = String(existingTask.section || '').toUpperCase();
|
|
289
|
-
if (section.includes('P0')) return 'P0';
|
|
290
|
-
if (section.includes('P1')) return 'P1';
|
|
291
|
-
if (section.includes('P2')) return 'P2';
|
|
292
|
-
return 'P1';
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function mergeWithExisting(candidates, existingTasks) {
|
|
296
|
-
const matchedExistingIds = new Set();
|
|
297
|
-
const merged = [];
|
|
298
|
-
|
|
299
|
-
for (const candidate of candidates) {
|
|
300
|
-
const match = findBestTaskMatch(candidate, existingTasks);
|
|
301
|
-
if (match) {
|
|
302
|
-
matchedExistingIds.add(match.task.id);
|
|
303
|
-
merged.push({
|
|
304
|
-
...candidate,
|
|
305
|
-
id: match.task.id,
|
|
306
|
-
checked: match.task.checked
|
|
307
|
-
});
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
merged.push(candidate);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
for (const existing of existingTasks) {
|
|
315
|
-
if (matchedExistingIds.has(existing.id)) {
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const phase = inferPhase(existing);
|
|
320
|
-
merged.push({
|
|
321
|
-
id: existing.id,
|
|
322
|
-
text: existing.text,
|
|
323
|
-
phase,
|
|
324
|
-
priority: phase,
|
|
325
|
-
checked: existing.checked,
|
|
326
|
-
source: 'existing'
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return dedupeTasks(merged);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function groupByPhase(tasks) {
|
|
334
|
-
const groups = { P0: [], P1: [], P2: [] };
|
|
335
|
-
for (const task of tasks) {
|
|
336
|
-
const phase = PHASE_ORDER.includes(task.phase) ? task.phase : 'P2';
|
|
337
|
-
groups[phase].push(task);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
for (const phase of PHASE_ORDER) {
|
|
341
|
-
groups[phase].sort((left, right) => left.text.localeCompare(right.text));
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return groups;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function inferProjectName(projectRoot) {
|
|
348
|
-
const pkgPath = path.join(projectRoot, 'package.json');
|
|
349
|
-
try {
|
|
350
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
351
|
-
if (pkg.name) return pkg.name;
|
|
352
|
-
} catch {
|
|
353
|
-
// ignore — try other manifests
|
|
354
|
-
}
|
|
355
|
-
return path.basename(projectRoot);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function buildPhasesDetailed(phases, config) {
|
|
359
|
-
const configPhases = config.product && Array.isArray(config.product.phases)
|
|
360
|
-
? config.product.phases : [];
|
|
361
|
-
if (configPhases.length > 0) return configPhases;
|
|
362
|
-
|
|
363
|
-
return [
|
|
364
|
-
{
|
|
365
|
-
phaseNumber: 1,
|
|
366
|
-
title: 'Foundation Baseline',
|
|
367
|
-
priority: 'P0',
|
|
368
|
-
objective: 'Establish a stable, testable baseline that unblocks all downstream delivery.',
|
|
369
|
-
steps: [{
|
|
370
|
-
stepNumber: 1,
|
|
371
|
-
title: 'Core Implementation',
|
|
372
|
-
priority: 'P0',
|
|
373
|
-
dependsOn: [],
|
|
374
|
-
objective: 'Close critical path items.',
|
|
375
|
-
tasks: phases.P0,
|
|
376
|
-
exitCriteria: [
|
|
377
|
-
{ text: 'All P0 tasks validated by evidence', priority: 'P0' },
|
|
378
|
-
{ text: 'CI is green on main', priority: 'P0' }
|
|
379
|
-
],
|
|
380
|
-
risks: []
|
|
381
|
-
}]
|
|
382
|
-
},
|
|
383
|
-
{
|
|
384
|
-
phaseNumber: 2,
|
|
385
|
-
title: 'Feature Completeness',
|
|
386
|
-
priority: 'P1',
|
|
387
|
-
objective: 'Expand functionality and reduce operational risk.',
|
|
388
|
-
steps: [{
|
|
389
|
-
stepNumber: 1,
|
|
390
|
-
title: 'Feature Delivery',
|
|
391
|
-
priority: 'P1',
|
|
392
|
-
dependsOn: [1],
|
|
393
|
-
objective: 'Deliver planned P1 features.',
|
|
394
|
-
tasks: phases.P1,
|
|
395
|
-
exitCriteria: [
|
|
396
|
-
{ text: 'All P1 tasks validated by evidence', priority: 'P1' },
|
|
397
|
-
{ text: 'No regressions on Phase 1 functionality', priority: 'P0' }
|
|
398
|
-
],
|
|
399
|
-
risks: []
|
|
400
|
-
}]
|
|
401
|
-
},
|
|
402
|
-
{
|
|
403
|
-
phaseNumber: 3,
|
|
404
|
-
title: 'Release Hardening',
|
|
405
|
-
priority: 'P2',
|
|
406
|
-
objective: 'Complete hardening and production readiness for v1.0.',
|
|
407
|
-
steps: [{
|
|
408
|
-
stepNumber: 1,
|
|
409
|
-
title: 'Hardening',
|
|
410
|
-
priority: 'P2',
|
|
411
|
-
dependsOn: [2],
|
|
412
|
-
objective: 'Close P2 items and harden release.',
|
|
413
|
-
tasks: phases.P2,
|
|
414
|
-
exitCriteria: [
|
|
415
|
-
{ text: 'All P2 tasks validated by evidence', priority: 'P2' },
|
|
416
|
-
{ text: 'Release candidate checklist complete', priority: 'P0' }
|
|
417
|
-
],
|
|
418
|
-
risks: []
|
|
419
|
-
}]
|
|
420
|
-
}
|
|
421
|
-
];
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function buildSteps(phases, config) {
|
|
425
|
-
const configSteps = config.product && Array.isArray(config.product.steps) ? config.product.steps : [];
|
|
426
|
-
if (configSteps.length > 0) return configSteps;
|
|
427
|
-
|
|
428
|
-
const stepDefs = [
|
|
429
|
-
{ stepNumber: 1, title: 'Foundation Baseline', priority: 'P0', dependsOn: [], phaseKey: 'P0',
|
|
430
|
-
objective: 'Establish a stable, testable baseline that unblocks all downstream delivery.' },
|
|
431
|
-
{ stepNumber: 2, title: 'Feature Completeness', priority: 'P1', dependsOn: [1], phaseKey: 'P1',
|
|
432
|
-
objective: 'Expand functionality, improve reliability, and reduce operational risk.' },
|
|
433
|
-
{ stepNumber: 3, title: 'Release Hardening', priority: 'P2', dependsOn: [2], phaseKey: 'P2',
|
|
434
|
-
objective: 'Complete hardening, final validation, and production readiness for v1.0.' }
|
|
435
|
-
];
|
|
436
|
-
|
|
437
|
-
const defaultExitCriteria = {
|
|
438
|
-
1: ['All P0 tasks validated by evidence', 'CI is green on main'],
|
|
439
|
-
2: ['All P1 tasks validated by evidence', 'No regressions on P0 functionality'],
|
|
440
|
-
3: ['All P2 tasks validated by evidence', 'Release candidate checklist complete']
|
|
441
|
-
};
|
|
442
|
-
|
|
443
|
-
return stepDefs.map((def) => ({
|
|
444
|
-
stepNumber: def.stepNumber,
|
|
445
|
-
title: def.title,
|
|
446
|
-
priority: def.priority,
|
|
447
|
-
dependsOn: def.dependsOn,
|
|
448
|
-
objective: def.objective,
|
|
449
|
-
deliverables: phases[def.phaseKey] || [],
|
|
450
|
-
exitCriteria: defaultExitCriteria[def.stepNumber] || [],
|
|
451
|
-
risks: []
|
|
452
|
-
}));
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function createModel(scan, tasks, config, customSections, checkedById) {
|
|
456
|
-
const phases = groupByPhase(tasks);
|
|
457
|
-
|
|
458
|
-
const implemented = [
|
|
459
|
-
`${scan.implementedCount} implementation files across ${scan.languages.join(', ') || 'detected stack'}`
|
|
460
|
-
];
|
|
461
|
-
|
|
462
|
-
const scaffold = scan.modules.length > 0
|
|
463
|
-
? scan.modules.slice(0, 6).map((m) => `Module "${m}" partially implemented — coverage unknown`)
|
|
464
|
-
: [];
|
|
465
|
-
|
|
466
|
-
const knownLimitations = (scan.codeTodos || []).slice(0, 6).map((t) => `${t.file}:${t.line} — ${t.text.slice(0, 80)}`);
|
|
467
|
-
|
|
468
|
-
const currentState = {
|
|
469
|
-
implemented,
|
|
470
|
-
scaffold,
|
|
471
|
-
knownLimitations,
|
|
472
|
-
workspaces: scan.workspaces || [],
|
|
473
|
-
implementedSummary: `${scan.implementedCount} implementation files detected`,
|
|
474
|
-
todoSummary: `${scan.todos.length} TODO/FIXME markers detected`,
|
|
475
|
-
stackSummary: scan.languages.length > 0 ? scan.languages.join(', ') : 'No language-specific stack detected'
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
const exitCriteria = [
|
|
479
|
-
'P0: all critical checklist items validated by code/test/artifact evidence',
|
|
480
|
-
'P1: reliability and regression checks green on the mainline',
|
|
481
|
-
'P2: release hardening and anti-goal checks completed for v1.0'
|
|
482
|
-
];
|
|
483
|
-
|
|
484
|
-
const commandBreakdown = [];
|
|
485
|
-
for (const moduleName of scan.modules.slice(0, 8)) {
|
|
486
|
-
commandBreakdown.push(`Module: ${moduleName}`);
|
|
487
|
-
}
|
|
488
|
-
for (const command of scan.commands.slice(0, 8)) {
|
|
489
|
-
commandBreakdown.push(`Command: ${command}`);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const productConfig = config.product || {};
|
|
493
|
-
const inferredName = inferProjectName(scan.projectRoot || process.cwd());
|
|
494
|
-
const product = {
|
|
495
|
-
name: productConfig.name || inferredName,
|
|
496
|
-
northStar: productConfig.northStar || '',
|
|
497
|
-
positioning: productConfig.positioning || '',
|
|
498
|
-
primaryUser: productConfig.primaryUser || '',
|
|
499
|
-
targetOutcome: productConfig.targetOutcome || ''
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
const defaultRisks = [
|
|
503
|
-
'Roadmap drift if checklist state diverges from repository evidence',
|
|
504
|
-
'Silent regressions when tasks are marked complete without tests',
|
|
505
|
-
'Scope creep that delays the v1.0 milestone path'
|
|
506
|
-
];
|
|
507
|
-
const defaultAntiGoals = [
|
|
508
|
-
'Do not mark tasks complete without repository evidence',
|
|
509
|
-
'Do not introduce non-deterministic roadmap formatting',
|
|
510
|
-
'Do not hide validation failures from roadmap consumers'
|
|
511
|
-
];
|
|
512
|
-
|
|
513
|
-
const risks = (productConfig.risks && productConfig.risks.length > 0) ? productConfig.risks : defaultRisks;
|
|
514
|
-
const antiGoals = (productConfig.antiGoals && productConfig.antiGoals.length > 0) ? productConfig.antiGoals : defaultAntiGoals;
|
|
515
|
-
const successCriteria = productConfig.successCriteria || [];
|
|
516
|
-
|
|
517
|
-
const northStar = productConfig.northStar
|
|
518
|
-
|| 'Ship validated, high-impact increments with deterministic delivery and transparent completion evidence.';
|
|
519
|
-
|
|
520
|
-
const steps = buildSteps(phases, config);
|
|
521
|
-
const phasesDetailed = buildPhasesDetailed(phases, config);
|
|
522
|
-
|
|
523
|
-
return createRoadmapModel({
|
|
524
|
-
northStar,
|
|
525
|
-
product,
|
|
526
|
-
currentState,
|
|
527
|
-
phases,
|
|
528
|
-
steps,
|
|
529
|
-
phasesDetailed,
|
|
530
|
-
milestones: config.milestones,
|
|
531
|
-
commandBreakdown,
|
|
532
|
-
exitCriteria,
|
|
533
|
-
risks,
|
|
534
|
-
antiGoals,
|
|
535
|
-
successCriteria,
|
|
536
|
-
customSections,
|
|
537
|
-
customPhases: config.customPhases || [],
|
|
538
|
-
checkedById
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
function normalizeCandidate(candidate) {
|
|
543
|
-
const phase = candidate.phase || candidate.priority || 'P1';
|
|
544
|
-
const priority = candidate.priority || phase;
|
|
545
|
-
return {
|
|
546
|
-
id: candidate.id || slugify(`${phase}-${candidate.text}`),
|
|
547
|
-
text: candidate.text,
|
|
548
|
-
phase,
|
|
549
|
-
priority,
|
|
550
|
-
checked: Boolean(candidate.checked),
|
|
551
|
-
source: candidate.source || 'plugin'
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
function generateRoadmapDocument(options) {
|
|
556
|
-
const projectRoot = options.projectRoot;
|
|
557
|
-
const config = options.config;
|
|
558
|
-
const plugins = options.plugins || [];
|
|
559
|
-
const existingContent = options.existingContent || '';
|
|
560
|
-
|
|
561
|
-
const scan = scanProject(projectRoot);
|
|
562
|
-
const existing = parseRoadmap(existingContent);
|
|
563
|
-
const existingCheckedById = {};
|
|
564
|
-
for (const task of existing.tasks) {
|
|
565
|
-
existingCheckedById[task.id] = task.checked;
|
|
566
|
-
}
|
|
567
|
-
const existingPhaseTasks = existing.tasks.filter((task) => /^Phase P[0-2]/i.test(String(task.section || '')));
|
|
568
|
-
|
|
569
|
-
const pluginTaskCandidates = collectPluginContributions(plugins, 'registerTaskDetectors', {
|
|
570
|
-
projectRoot,
|
|
571
|
-
config,
|
|
572
|
-
scan
|
|
573
|
-
}).map(normalizeCandidate);
|
|
574
|
-
|
|
575
|
-
const pluginSections = collectPluginContributions(plugins, 'registerSectionGenerators', {
|
|
576
|
-
projectRoot,
|
|
577
|
-
config,
|
|
578
|
-
scan
|
|
579
|
-
}).map((section) => ({
|
|
580
|
-
title: section.title,
|
|
581
|
-
items: section.items || []
|
|
582
|
-
}));
|
|
583
|
-
|
|
584
|
-
const configSections = (config.customSections || []).map((section) => ({
|
|
585
|
-
title: section.title,
|
|
586
|
-
items: section.items || []
|
|
587
|
-
}));
|
|
588
|
-
|
|
589
|
-
const evidenceLine = scan.classifierSignals.length > 0
|
|
590
|
-
? scan.classifierSignals.slice(0, 5).join(', ')
|
|
591
|
-
: 'general file scan';
|
|
592
|
-
const profileSection = {
|
|
593
|
-
title: 'Detected Project Profile',
|
|
594
|
-
items: [
|
|
595
|
-
`- **Type:** ${scan.projectType}`,
|
|
596
|
-
`- **Confidence:** ${scan.classifierConfidence}`,
|
|
597
|
-
`- **Evidence:** ${evidenceLine}`
|
|
598
|
-
]
|
|
599
|
-
};
|
|
600
|
-
|
|
601
|
-
const baseCandidates = buildDefaultCandidates(scan, config);
|
|
602
|
-
const matcherCandidates = applyTaskMatchers(scan, config);
|
|
603
|
-
const merged = mergeWithExisting([...baseCandidates, ...matcherCandidates, ...pluginTaskCandidates], existingPhaseTasks);
|
|
604
|
-
const model = createModel(scan, merged, config, [profileSection, ...configSections, ...pluginSections], existingCheckedById);
|
|
605
|
-
const profile = config.roadmapProfile || 'compact';
|
|
606
|
-
const managedBody = renderBody(model, profile);
|
|
607
|
-
|
|
608
|
-
return upsertManagedBlock(existingContent, managedBody);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
module.exports = {
|
|
612
|
-
generateRoadmapDocument,
|
|
613
|
-
scanProject
|
|
614
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { walkFiles, detectLanguages, detectTestFrameworks, detectWorkspaces } = require('../io');
|
|
6
|
+
const { createRoadmapModel, PHASE_ORDER } = require('../model');
|
|
7
|
+
const { slugify, ensureTrailingNewline } = require('../utils');
|
|
8
|
+
const { parseRoadmap, upsertManagedBlock } = require('../parser');
|
|
9
|
+
const { findBestTaskMatch, dedupeTasks } = require('../match');
|
|
10
|
+
const { collectPluginContributions } = require('../config');
|
|
11
|
+
const { renderBody } = require('../renderer');
|
|
12
|
+
const { classifyProject } = require('../classifier');
|
|
13
|
+
|
|
14
|
+
const IMPL_PATTERN_RE = /[/|]TODO|TODO[|/]|[/|]FIXME|FIXME[|/]/;
|
|
15
|
+
const COMMENT_TODO_RE = /(?:\/\/|#|\*\s*).*\b(?:TODO|FIXME)\b/;
|
|
16
|
+
|
|
17
|
+
function isTodoMarker(line) {
|
|
18
|
+
return COMMENT_TODO_RE.test(line) && !IMPL_PATTERN_RE.test(line);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const GENERIC_MODULE_NAMES = new Set(['index', 'main', 'utils', 'common', 'helpers', 'types', 'constants', 'model']);
|
|
22
|
+
|
|
23
|
+
function detectModules(files) {
|
|
24
|
+
const modules = new Set();
|
|
25
|
+
const rootPrefixes = ['src/', 'apps/', 'packages/', 'lib/', 'cmd/', 'internal/'];
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
let relative;
|
|
29
|
+
|
|
30
|
+
const directRoot = rootPrefixes.find((r) => file.startsWith(r));
|
|
31
|
+
if (directRoot) {
|
|
32
|
+
relative = file.slice(directRoot.length);
|
|
33
|
+
} else {
|
|
34
|
+
let found = false;
|
|
35
|
+
for (const r of rootPrefixes) {
|
|
36
|
+
const idx = file.indexOf('/' + r);
|
|
37
|
+
if (idx !== -1) {
|
|
38
|
+
// Only accept nested prefix when it appears within the first two path segments
|
|
39
|
+
// (e.g. "wrapper/src/..." is fine; "a/b/c/src/..." is too deep and likely a fixture or dependency)
|
|
40
|
+
if (file.slice(0, idx).split('/').length > 2) continue;
|
|
41
|
+
relative = file.slice(idx + 1 + r.length);
|
|
42
|
+
found = true;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!found) continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const first = relative.split('/')[0];
|
|
50
|
+
if (!first) continue;
|
|
51
|
+
|
|
52
|
+
if (first.includes('.')) {
|
|
53
|
+
const name = first.slice(0, first.lastIndexOf('.'));
|
|
54
|
+
if (name && !GENERIC_MODULE_NAMES.has(name)) {
|
|
55
|
+
modules.add(name);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
modules.add(first);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Array.from(modules).sort((left, right) => left.localeCompare(right));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function detectCommands(files) {
|
|
66
|
+
const commands = new Set();
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
if (file.startsWith('bin/')) {
|
|
69
|
+
commands.add(path.basename(file, path.extname(file)));
|
|
70
|
+
}
|
|
71
|
+
if (file.startsWith('cmd/')) {
|
|
72
|
+
commands.add(file.split('/')[1] || file);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return Array.from(commands).sort((left, right) => left.localeCompare(right));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collectTodoHints(projectRoot, files) {
|
|
79
|
+
const hints = [];
|
|
80
|
+
const relevant = files.filter((file) => /\.(js|ts|tsx|py|go|rs|md)$/.test(file)).slice(0, 120);
|
|
81
|
+
|
|
82
|
+
for (const file of relevant) {
|
|
83
|
+
const absolutePath = path.resolve(projectRoot, file);
|
|
84
|
+
let content = '';
|
|
85
|
+
try {
|
|
86
|
+
content = fs.readFileSync(absolutePath, 'utf8');
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lines = content.split(/\r?\n/);
|
|
92
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
93
|
+
if (isTodoMarker(lines[i])) {
|
|
94
|
+
hints.push({
|
|
95
|
+
file,
|
|
96
|
+
line: i + 1,
|
|
97
|
+
text: lines[i].trim()
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (hints.length >= 12) {
|
|
101
|
+
return hints;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return hints;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function collectCodeTodoHints(projectRoot, files) {
|
|
110
|
+
const hints = [];
|
|
111
|
+
const codeFiles = files.filter((file) => /\.(js|ts|tsx|py|go|rs)$/.test(file)).slice(0, 120);
|
|
112
|
+
|
|
113
|
+
for (const file of codeFiles) {
|
|
114
|
+
const absolutePath = path.resolve(projectRoot, file);
|
|
115
|
+
let content = '';
|
|
116
|
+
try {
|
|
117
|
+
content = fs.readFileSync(absolutePath, 'utf8');
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lines = content.split(/\r?\n/);
|
|
123
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
124
|
+
if (isTodoMarker(lines[i])) {
|
|
125
|
+
hints.push({
|
|
126
|
+
file,
|
|
127
|
+
line: i + 1,
|
|
128
|
+
text: lines[i].trim()
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (hints.length >= 6) {
|
|
132
|
+
return hints;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return hints;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function scanProject(projectRoot) {
|
|
141
|
+
const files = walkFiles(projectRoot);
|
|
142
|
+
const languages = detectLanguages(files);
|
|
143
|
+
const testFrameworks = detectTestFrameworks(projectRoot, files);
|
|
144
|
+
const modules = detectModules(files);
|
|
145
|
+
const commands = detectCommands(files);
|
|
146
|
+
const todos = collectTodoHints(projectRoot, files);
|
|
147
|
+
const codeTodos = collectCodeTodoHints(projectRoot, files);
|
|
148
|
+
const workspaces = detectWorkspaces(projectRoot, files);
|
|
149
|
+
|
|
150
|
+
const implementedFiles = files.filter((file) => /\.(js|ts|tsx|py|go|rs|java|kt|cs)$/.test(file));
|
|
151
|
+
const testFiles = files.filter((file) => /(^|\/)(__tests__|tests)\//.test(file) || /\.test\.|\.spec\.|_test\.go$/.test(file));
|
|
152
|
+
|
|
153
|
+
const classifier = classifyProject({ projectRoot, files });
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
projectRoot,
|
|
157
|
+
files,
|
|
158
|
+
languages,
|
|
159
|
+
testFrameworks,
|
|
160
|
+
modules,
|
|
161
|
+
commands,
|
|
162
|
+
todos,
|
|
163
|
+
codeTodos,
|
|
164
|
+
workspaces,
|
|
165
|
+
implementedCount: implementedFiles.length,
|
|
166
|
+
testCount: testFiles.length,
|
|
167
|
+
projectType: classifier.type,
|
|
168
|
+
classifierConfidence: classifier.confidence,
|
|
169
|
+
classifierSignals: classifier.signals
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toCandidate(text, phase, priority, source = 'default') {
|
|
174
|
+
return {
|
|
175
|
+
id: slugify(`${phase}-${text}`),
|
|
176
|
+
text,
|
|
177
|
+
phase,
|
|
178
|
+
priority,
|
|
179
|
+
checked: false,
|
|
180
|
+
source
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const WEB_CANDIDATES_COMMON = [
|
|
185
|
+
{ text: 'Add SEO metadata: title, description, and canonical URL for all pages', phase: 'P0' },
|
|
186
|
+
{ text: 'Implement responsive and mobile-first layout across all breakpoints', phase: 'P0' },
|
|
187
|
+
{ text: 'Establish accessibility baseline (semantic HTML, ARIA labels, keyboard navigation)', phase: 'P0' },
|
|
188
|
+
{ text: 'Add OpenGraph and Twitter card metadata for social sharing', phase: 'P1' },
|
|
189
|
+
{ text: 'Achieve Lighthouse performance score ≥ 90 and resolve critical findings', phase: 'P1' },
|
|
190
|
+
{ text: 'Validate branding consistency: typography, color tokens, and logo usage', phase: 'P1' },
|
|
191
|
+
{ text: 'Configure deployment and hosting pipeline (CI/CD to production)', phase: 'P2' },
|
|
192
|
+
{ text: 'Add web security headers: Content-Security-Policy, X-Frame-Options, HSTS', phase: 'P2' }
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
const LANDING_CANDIDATES = [
|
|
196
|
+
{ text: 'Complete services and content sections with clear value proposition', phase: 'P1' },
|
|
197
|
+
{ text: 'Implement contact form and conversion flow with input validation', phase: 'P1' },
|
|
198
|
+
{ text: 'Set up analytics and conversion event tracking', phase: 'P2' }
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
function buildWebCandidates(scan) {
|
|
202
|
+
const candidates = WEB_CANDIDATES_COMMON.map(({ text, phase }) =>
|
|
203
|
+
toCandidate(text, phase, phase, 'classifier')
|
|
204
|
+
);
|
|
205
|
+
if (scan.projectType === 'landing-site') {
|
|
206
|
+
for (const { text, phase } of LANDING_CANDIDATES) {
|
|
207
|
+
candidates.push(toCandidate(text, phase, phase, 'classifier'));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return candidates;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildDefaultCandidates(scan, config) {
|
|
214
|
+
const languageLabel = scan.languages.length > 0 ? scan.languages.join(', ') : 'current stack';
|
|
215
|
+
const candidates = [];
|
|
216
|
+
|
|
217
|
+
const p0 = [
|
|
218
|
+
...config.phaseTemplates.P0,
|
|
219
|
+
`Document measurable north star metrics for ${languageLabel}`,
|
|
220
|
+
'Close critical TODO and FIXME items blocking release confidence'
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
if (scan.testFrameworks.length === 0) {
|
|
224
|
+
p0.push(`Add automated test harness for ${languageLabel}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const item of p0) {
|
|
228
|
+
candidates.push(toCandidate(item, 'P0', 'P0'));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const p1 = [
|
|
232
|
+
...config.phaseTemplates.P1,
|
|
233
|
+
'Expand feature-level validation and regression checks'
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const moduleName of scan.modules.slice(0, 5)) {
|
|
237
|
+
p1.push(`Finalize module implementation: ${moduleName}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const commandName of scan.commands.slice(0, 5)) {
|
|
241
|
+
p1.push(`Harden command behavior and error handling: ${commandName}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const item of p1) {
|
|
245
|
+
candidates.push(toCandidate(item, 'P1', 'P1'));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const p2 = [
|
|
249
|
+
...config.phaseTemplates.P2,
|
|
250
|
+
'Complete release candidate checklist and production readiness review'
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
for (const item of p2) {
|
|
254
|
+
candidates.push(toCandidate(item, 'P2', 'P2'));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const hint of scan.todos.slice(0, 5)) {
|
|
258
|
+
candidates.push(toCandidate(`Resolve backlog note in ${hint.file}`, 'P0', 'P0', 'todo-hint'));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (scan.projectType === 'frontend-web' || scan.projectType === 'landing-site') {
|
|
262
|
+
candidates.push(...buildWebCandidates(scan));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return candidates;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function applyTaskMatchers(scan, config) {
|
|
269
|
+
const candidates = [];
|
|
270
|
+
for (const matcher of config.taskMatchers || []) {
|
|
271
|
+
if (!matcher || !matcher.pattern || !matcher.task) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const regex = new RegExp(matcher.pattern, 'i');
|
|
276
|
+
if (!scan.files.some((file) => regex.test(file))) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const phase = matcher.phase || matcher.priority || 'P1';
|
|
281
|
+
const priority = matcher.priority || phase;
|
|
282
|
+
candidates.push(toCandidate(matcher.task, phase, priority, 'task-matcher'));
|
|
283
|
+
}
|
|
284
|
+
return candidates;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function inferPhase(existingTask) {
|
|
288
|
+
const section = String(existingTask.section || '').toUpperCase();
|
|
289
|
+
if (section.includes('P0')) return 'P0';
|
|
290
|
+
if (section.includes('P1')) return 'P1';
|
|
291
|
+
if (section.includes('P2')) return 'P2';
|
|
292
|
+
return 'P1';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function mergeWithExisting(candidates, existingTasks) {
|
|
296
|
+
const matchedExistingIds = new Set();
|
|
297
|
+
const merged = [];
|
|
298
|
+
|
|
299
|
+
for (const candidate of candidates) {
|
|
300
|
+
const match = findBestTaskMatch(candidate, existingTasks);
|
|
301
|
+
if (match) {
|
|
302
|
+
matchedExistingIds.add(match.task.id);
|
|
303
|
+
merged.push({
|
|
304
|
+
...candidate,
|
|
305
|
+
id: match.task.id,
|
|
306
|
+
checked: match.task.checked
|
|
307
|
+
});
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
merged.push(candidate);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const existing of existingTasks) {
|
|
315
|
+
if (matchedExistingIds.has(existing.id)) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const phase = inferPhase(existing);
|
|
320
|
+
merged.push({
|
|
321
|
+
id: existing.id,
|
|
322
|
+
text: existing.text,
|
|
323
|
+
phase,
|
|
324
|
+
priority: phase,
|
|
325
|
+
checked: existing.checked,
|
|
326
|
+
source: 'existing'
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return dedupeTasks(merged);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function groupByPhase(tasks) {
|
|
334
|
+
const groups = { P0: [], P1: [], P2: [] };
|
|
335
|
+
for (const task of tasks) {
|
|
336
|
+
const phase = PHASE_ORDER.includes(task.phase) ? task.phase : 'P2';
|
|
337
|
+
groups[phase].push(task);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
for (const phase of PHASE_ORDER) {
|
|
341
|
+
groups[phase].sort((left, right) => left.text.localeCompare(right.text));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return groups;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function inferProjectName(projectRoot) {
|
|
348
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
349
|
+
try {
|
|
350
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
351
|
+
if (pkg.name) return pkg.name;
|
|
352
|
+
} catch {
|
|
353
|
+
// ignore — try other manifests
|
|
354
|
+
}
|
|
355
|
+
return path.basename(projectRoot);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function buildPhasesDetailed(phases, config) {
|
|
359
|
+
const configPhases = config.product && Array.isArray(config.product.phases)
|
|
360
|
+
? config.product.phases : [];
|
|
361
|
+
if (configPhases.length > 0) return configPhases;
|
|
362
|
+
|
|
363
|
+
return [
|
|
364
|
+
{
|
|
365
|
+
phaseNumber: 1,
|
|
366
|
+
title: 'Foundation Baseline',
|
|
367
|
+
priority: 'P0',
|
|
368
|
+
objective: 'Establish a stable, testable baseline that unblocks all downstream delivery.',
|
|
369
|
+
steps: [{
|
|
370
|
+
stepNumber: 1,
|
|
371
|
+
title: 'Core Implementation',
|
|
372
|
+
priority: 'P0',
|
|
373
|
+
dependsOn: [],
|
|
374
|
+
objective: 'Close critical path items.',
|
|
375
|
+
tasks: phases.P0,
|
|
376
|
+
exitCriteria: [
|
|
377
|
+
{ text: 'All P0 tasks validated by evidence', priority: 'P0' },
|
|
378
|
+
{ text: 'CI is green on main', priority: 'P0' }
|
|
379
|
+
],
|
|
380
|
+
risks: []
|
|
381
|
+
}]
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
phaseNumber: 2,
|
|
385
|
+
title: 'Feature Completeness',
|
|
386
|
+
priority: 'P1',
|
|
387
|
+
objective: 'Expand functionality and reduce operational risk.',
|
|
388
|
+
steps: [{
|
|
389
|
+
stepNumber: 1,
|
|
390
|
+
title: 'Feature Delivery',
|
|
391
|
+
priority: 'P1',
|
|
392
|
+
dependsOn: [1],
|
|
393
|
+
objective: 'Deliver planned P1 features.',
|
|
394
|
+
tasks: phases.P1,
|
|
395
|
+
exitCriteria: [
|
|
396
|
+
{ text: 'All P1 tasks validated by evidence', priority: 'P1' },
|
|
397
|
+
{ text: 'No regressions on Phase 1 functionality', priority: 'P0' }
|
|
398
|
+
],
|
|
399
|
+
risks: []
|
|
400
|
+
}]
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
phaseNumber: 3,
|
|
404
|
+
title: 'Release Hardening',
|
|
405
|
+
priority: 'P2',
|
|
406
|
+
objective: 'Complete hardening and production readiness for v1.0.',
|
|
407
|
+
steps: [{
|
|
408
|
+
stepNumber: 1,
|
|
409
|
+
title: 'Hardening',
|
|
410
|
+
priority: 'P2',
|
|
411
|
+
dependsOn: [2],
|
|
412
|
+
objective: 'Close P2 items and harden release.',
|
|
413
|
+
tasks: phases.P2,
|
|
414
|
+
exitCriteria: [
|
|
415
|
+
{ text: 'All P2 tasks validated by evidence', priority: 'P2' },
|
|
416
|
+
{ text: 'Release candidate checklist complete', priority: 'P0' }
|
|
417
|
+
],
|
|
418
|
+
risks: []
|
|
419
|
+
}]
|
|
420
|
+
}
|
|
421
|
+
];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function buildSteps(phases, config) {
|
|
425
|
+
const configSteps = config.product && Array.isArray(config.product.steps) ? config.product.steps : [];
|
|
426
|
+
if (configSteps.length > 0) return configSteps;
|
|
427
|
+
|
|
428
|
+
const stepDefs = [
|
|
429
|
+
{ stepNumber: 1, title: 'Foundation Baseline', priority: 'P0', dependsOn: [], phaseKey: 'P0',
|
|
430
|
+
objective: 'Establish a stable, testable baseline that unblocks all downstream delivery.' },
|
|
431
|
+
{ stepNumber: 2, title: 'Feature Completeness', priority: 'P1', dependsOn: [1], phaseKey: 'P1',
|
|
432
|
+
objective: 'Expand functionality, improve reliability, and reduce operational risk.' },
|
|
433
|
+
{ stepNumber: 3, title: 'Release Hardening', priority: 'P2', dependsOn: [2], phaseKey: 'P2',
|
|
434
|
+
objective: 'Complete hardening, final validation, and production readiness for v1.0.' }
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
const defaultExitCriteria = {
|
|
438
|
+
1: ['All P0 tasks validated by evidence', 'CI is green on main'],
|
|
439
|
+
2: ['All P1 tasks validated by evidence', 'No regressions on P0 functionality'],
|
|
440
|
+
3: ['All P2 tasks validated by evidence', 'Release candidate checklist complete']
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
return stepDefs.map((def) => ({
|
|
444
|
+
stepNumber: def.stepNumber,
|
|
445
|
+
title: def.title,
|
|
446
|
+
priority: def.priority,
|
|
447
|
+
dependsOn: def.dependsOn,
|
|
448
|
+
objective: def.objective,
|
|
449
|
+
deliverables: phases[def.phaseKey] || [],
|
|
450
|
+
exitCriteria: defaultExitCriteria[def.stepNumber] || [],
|
|
451
|
+
risks: []
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function createModel(scan, tasks, config, customSections, checkedById) {
|
|
456
|
+
const phases = groupByPhase(tasks);
|
|
457
|
+
|
|
458
|
+
const implemented = [
|
|
459
|
+
`${scan.implementedCount} implementation files across ${scan.languages.join(', ') || 'detected stack'}`
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
const scaffold = scan.modules.length > 0
|
|
463
|
+
? scan.modules.slice(0, 6).map((m) => `Module "${m}" partially implemented — coverage unknown`)
|
|
464
|
+
: [];
|
|
465
|
+
|
|
466
|
+
const knownLimitations = (scan.codeTodos || []).slice(0, 6).map((t) => `${t.file}:${t.line} — ${t.text.slice(0, 80)}`);
|
|
467
|
+
|
|
468
|
+
const currentState = {
|
|
469
|
+
implemented,
|
|
470
|
+
scaffold,
|
|
471
|
+
knownLimitations,
|
|
472
|
+
workspaces: scan.workspaces || [],
|
|
473
|
+
implementedSummary: `${scan.implementedCount} implementation files detected`,
|
|
474
|
+
todoSummary: `${scan.todos.length} TODO/FIXME markers detected`,
|
|
475
|
+
stackSummary: scan.languages.length > 0 ? scan.languages.join(', ') : 'No language-specific stack detected'
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const exitCriteria = [
|
|
479
|
+
'P0: all critical checklist items validated by code/test/artifact evidence',
|
|
480
|
+
'P1: reliability and regression checks green on the mainline',
|
|
481
|
+
'P2: release hardening and anti-goal checks completed for v1.0'
|
|
482
|
+
];
|
|
483
|
+
|
|
484
|
+
const commandBreakdown = [];
|
|
485
|
+
for (const moduleName of scan.modules.slice(0, 8)) {
|
|
486
|
+
commandBreakdown.push(`Module: ${moduleName}`);
|
|
487
|
+
}
|
|
488
|
+
for (const command of scan.commands.slice(0, 8)) {
|
|
489
|
+
commandBreakdown.push(`Command: ${command}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const productConfig = config.product || {};
|
|
493
|
+
const inferredName = inferProjectName(scan.projectRoot || process.cwd());
|
|
494
|
+
const product = {
|
|
495
|
+
name: productConfig.name || inferredName,
|
|
496
|
+
northStar: productConfig.northStar || '',
|
|
497
|
+
positioning: productConfig.positioning || '',
|
|
498
|
+
primaryUser: productConfig.primaryUser || '',
|
|
499
|
+
targetOutcome: productConfig.targetOutcome || ''
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const defaultRisks = [
|
|
503
|
+
'Roadmap drift if checklist state diverges from repository evidence',
|
|
504
|
+
'Silent regressions when tasks are marked complete without tests',
|
|
505
|
+
'Scope creep that delays the v1.0 milestone path'
|
|
506
|
+
];
|
|
507
|
+
const defaultAntiGoals = [
|
|
508
|
+
'Do not mark tasks complete without repository evidence',
|
|
509
|
+
'Do not introduce non-deterministic roadmap formatting',
|
|
510
|
+
'Do not hide validation failures from roadmap consumers'
|
|
511
|
+
];
|
|
512
|
+
|
|
513
|
+
const risks = (productConfig.risks && productConfig.risks.length > 0) ? productConfig.risks : defaultRisks;
|
|
514
|
+
const antiGoals = (productConfig.antiGoals && productConfig.antiGoals.length > 0) ? productConfig.antiGoals : defaultAntiGoals;
|
|
515
|
+
const successCriteria = productConfig.successCriteria || [];
|
|
516
|
+
|
|
517
|
+
const northStar = productConfig.northStar
|
|
518
|
+
|| 'Ship validated, high-impact increments with deterministic delivery and transparent completion evidence.';
|
|
519
|
+
|
|
520
|
+
const steps = buildSteps(phases, config);
|
|
521
|
+
const phasesDetailed = buildPhasesDetailed(phases, config);
|
|
522
|
+
|
|
523
|
+
return createRoadmapModel({
|
|
524
|
+
northStar,
|
|
525
|
+
product,
|
|
526
|
+
currentState,
|
|
527
|
+
phases,
|
|
528
|
+
steps,
|
|
529
|
+
phasesDetailed,
|
|
530
|
+
milestones: config.milestones,
|
|
531
|
+
commandBreakdown,
|
|
532
|
+
exitCriteria,
|
|
533
|
+
risks,
|
|
534
|
+
antiGoals,
|
|
535
|
+
successCriteria,
|
|
536
|
+
customSections,
|
|
537
|
+
customPhases: config.customPhases || [],
|
|
538
|
+
checkedById
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function normalizeCandidate(candidate) {
|
|
543
|
+
const phase = candidate.phase || candidate.priority || 'P1';
|
|
544
|
+
const priority = candidate.priority || phase;
|
|
545
|
+
return {
|
|
546
|
+
id: candidate.id || slugify(`${phase}-${candidate.text}`),
|
|
547
|
+
text: candidate.text,
|
|
548
|
+
phase,
|
|
549
|
+
priority,
|
|
550
|
+
checked: Boolean(candidate.checked),
|
|
551
|
+
source: candidate.source || 'plugin'
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function generateRoadmapDocument(options) {
|
|
556
|
+
const projectRoot = options.projectRoot;
|
|
557
|
+
const config = options.config;
|
|
558
|
+
const plugins = options.plugins || [];
|
|
559
|
+
const existingContent = options.existingContent || '';
|
|
560
|
+
|
|
561
|
+
const scan = scanProject(projectRoot);
|
|
562
|
+
const existing = parseRoadmap(existingContent);
|
|
563
|
+
const existingCheckedById = {};
|
|
564
|
+
for (const task of existing.tasks) {
|
|
565
|
+
existingCheckedById[task.id] = task.checked;
|
|
566
|
+
}
|
|
567
|
+
const existingPhaseTasks = existing.tasks.filter((task) => /^Phase P[0-2]/i.test(String(task.section || '')));
|
|
568
|
+
|
|
569
|
+
const pluginTaskCandidates = collectPluginContributions(plugins, 'registerTaskDetectors', {
|
|
570
|
+
projectRoot,
|
|
571
|
+
config,
|
|
572
|
+
scan
|
|
573
|
+
}).map(normalizeCandidate);
|
|
574
|
+
|
|
575
|
+
const pluginSections = collectPluginContributions(plugins, 'registerSectionGenerators', {
|
|
576
|
+
projectRoot,
|
|
577
|
+
config,
|
|
578
|
+
scan
|
|
579
|
+
}).map((section) => ({
|
|
580
|
+
title: section.title,
|
|
581
|
+
items: section.items || []
|
|
582
|
+
}));
|
|
583
|
+
|
|
584
|
+
const configSections = (config.customSections || []).map((section) => ({
|
|
585
|
+
title: section.title,
|
|
586
|
+
items: section.items || []
|
|
587
|
+
}));
|
|
588
|
+
|
|
589
|
+
const evidenceLine = scan.classifierSignals.length > 0
|
|
590
|
+
? scan.classifierSignals.slice(0, 5).join(', ')
|
|
591
|
+
: 'general file scan';
|
|
592
|
+
const profileSection = {
|
|
593
|
+
title: 'Detected Project Profile',
|
|
594
|
+
items: [
|
|
595
|
+
`- **Type:** ${scan.projectType}`,
|
|
596
|
+
`- **Confidence:** ${scan.classifierConfidence}`,
|
|
597
|
+
`- **Evidence:** ${evidenceLine}`
|
|
598
|
+
]
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const baseCandidates = buildDefaultCandidates(scan, config);
|
|
602
|
+
const matcherCandidates = applyTaskMatchers(scan, config);
|
|
603
|
+
const merged = mergeWithExisting([...baseCandidates, ...matcherCandidates, ...pluginTaskCandidates], existingPhaseTasks);
|
|
604
|
+
const model = createModel(scan, merged, config, [profileSection, ...configSections, ...pluginSections], existingCheckedById);
|
|
605
|
+
const profile = config.roadmapProfile || 'compact';
|
|
606
|
+
const managedBody = renderBody(model, profile);
|
|
607
|
+
|
|
608
|
+
return upsertManagedBlock(existingContent, managedBody);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
module.exports = {
|
|
612
|
+
generateRoadmapDocument,
|
|
613
|
+
scanProject
|
|
614
|
+
};
|