roadmapsmith 0.5.0 → 0.6.0
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/bin/cli.js +215 -211
- package/package.json +11 -3
- package/src/config.js +7 -0
- package/src/renderer/professional.js +4 -2
- package/src/validator/index.js +425 -420
package/bin/cli.js
CHANGED
|
@@ -1,212 +1,216 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const { parseArgv } = require('../src/utils');
|
|
7
|
-
const { loadConfig, resolveRoadmapFile, resolveAgentsFile, loadPlugins } = require('../src/config');
|
|
8
|
-
const { readTextIfExists, writeText, printDryRunDiff } = require('../src/io');
|
|
9
|
-
const { renderRoadmapTemplate, renderAgentsTemplate } = require('../src/templates');
|
|
10
|
-
const { generateRoadmapDocument } = require('../src/generator');
|
|
11
|
-
const { parseRoadmap } = require('../src/parser');
|
|
12
|
-
const { buildValidationContext, validateTasks, auditValidation } = require('../src/validator');
|
|
13
|
-
const { applySync } = require('../src/sync');
|
|
14
|
-
|
|
15
|
-
function printHelp() {
|
|
16
|
-
console.log([
|
|
17
|
-
'Usage:',
|
|
18
|
-
' roadmapsmith init [--roadmap-file <path>] [--agents-file <path>] [--dry-run]',
|
|
19
|
-
' roadmapsmith generate [--project-root <path>] [--config <path>] [--roadmap-file <path>] [--dry-run] [--audit]',
|
|
20
|
-
' roadmapsmith sync [--roadmap-file <path>] [--project-root <path>] [--config <path>] [--dry-run] [--audit]',
|
|
21
|
-
' roadmapsmith validate [--roadmap-file <path>] [--project-root <path>] [--config <path>] [--task <id|text>] [--json]'
|
|
22
|
-
].join('\n'));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function isEnabled(value) {
|
|
26
|
-
if (value === true) return true;
|
|
27
|
-
if (typeof value !== 'string') return false;
|
|
28
|
-
const normalized = value.toLowerCase();
|
|
29
|
-
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function formatResultLine(task, result) {
|
|
33
|
-
const status = result.passed ? 'PASS' : 'FAIL';
|
|
34
|
-
const reason = result.reasons.length > 0 ? ` :: ${result.reasons.join('; ')}` : '';
|
|
35
|
-
return `${status} [${task.id}] ${task.text}${reason}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function maybeFilterTasks(tasks, filterValue) {
|
|
39
|
-
if (!filterValue) return tasks;
|
|
40
|
-
const normalized = String(filterValue).toLowerCase();
|
|
41
|
-
return tasks.filter((task) => {
|
|
42
|
-
return task.id.toLowerCase() === normalized || task.text.toLowerCase().includes(normalized);
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function printAudit(audit) {
|
|
47
|
-
console.log(`Audit summary: ${audit.checkedWithoutEvidence.length} checked-without-evidence, ${audit.readyButUnchecked.length} ready-but-unchecked.`);
|
|
48
|
-
if (audit.checkedWithoutEvidence.length > 0) {
|
|
49
|
-
console.log('Checked without evidence:');
|
|
50
|
-
audit.checkedWithoutEvidence.forEach((item) => {
|
|
51
|
-
console.log(`- [${item.task.id}] ${item.task.text}`);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
if (audit.readyButUnchecked.length > 0) {
|
|
55
|
-
console.log('Ready but unchecked:');
|
|
56
|
-
audit.readyButUnchecked.forEach((item) => {
|
|
57
|
-
console.log(`- [${item.task.id}] ${item.task.text}`);
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function run() {
|
|
63
|
-
const parsed = parseArgv(process.argv.slice(2));
|
|
64
|
-
const command = parsed.command;
|
|
65
|
-
const flags = parsed.flags;
|
|
66
|
-
|
|
67
|
-
if (!command || isEnabled(flags.help) || isEnabled(flags.h)) {
|
|
68
|
-
printHelp();
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (command === 'init') {
|
|
73
|
-
const projectRoot = process.cwd();
|
|
74
|
-
const config = loadConfig({ projectRoot });
|
|
75
|
-
const roadmapFile = resolveRoadmapFile(projectRoot, config, flags['roadmap-file']);
|
|
76
|
-
const agentsFile = resolveAgentsFile(projectRoot, config, flags['agents-file']);
|
|
77
|
-
const dryRun = isEnabled(flags['dry-run']);
|
|
78
|
-
|
|
79
|
-
const roadmapExists = fs.existsSync(roadmapFile);
|
|
80
|
-
const agentsExists = fs.existsSync(agentsFile);
|
|
81
|
-
|
|
82
|
-
if (!roadmapExists) {
|
|
83
|
-
const roadmap = renderRoadmapTemplate();
|
|
84
|
-
const result = writeText(roadmapFile, roadmap, { dryRun });
|
|
85
|
-
if (dryRun && result.changed) {
|
|
86
|
-
printDryRunDiff(roadmapFile, result.before, result.after);
|
|
87
|
-
}
|
|
88
|
-
console.log(`${dryRun ? 'Would create' : 'Created'} ${roadmapFile}`);
|
|
89
|
-
} else {
|
|
90
|
-
console.log(`Skipped existing ${roadmapFile}`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!agentsExists) {
|
|
94
|
-
const agents = renderAgentsTemplate({ roadmapPath: path.basename(roadmapFile) });
|
|
95
|
-
const result = writeText(agentsFile, agents, { dryRun });
|
|
96
|
-
if (dryRun && result.changed) {
|
|
97
|
-
printDryRunDiff(agentsFile, result.before, result.after);
|
|
98
|
-
}
|
|
99
|
-
console.log(`${dryRun ? 'Would create' : 'Created'} ${agentsFile}`);
|
|
100
|
-
} else {
|
|
101
|
-
console.log(`Skipped existing ${agentsFile}`);
|
|
102
|
-
}
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (command === 'generate') {
|
|
107
|
-
const projectRoot = path.resolve(String(flags['project-root'] || process.cwd()));
|
|
108
|
-
const config = loadConfig({ projectRoot, configPath: flags.config });
|
|
109
|
-
const roadmapFile = resolveRoadmapFile(projectRoot, config, flags['roadmap-file']);
|
|
110
|
-
const plugins = loadPlugins(projectRoot, config.plugins);
|
|
111
|
-
const existingContent = readTextIfExists(roadmapFile) || '';
|
|
112
|
-
const dryRun = isEnabled(flags['dry-run']);
|
|
113
|
-
|
|
114
|
-
const document = generateRoadmapDocument({
|
|
115
|
-
projectRoot,
|
|
116
|
-
roadmapPath: roadmapFile,
|
|
117
|
-
existingContent,
|
|
118
|
-
config,
|
|
119
|
-
plugins
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const writeResult = writeText(roadmapFile, document, { dryRun });
|
|
123
|
-
if (dryRun) {
|
|
124
|
-
if (writeResult.changed) {
|
|
125
|
-
printDryRunDiff(roadmapFile, writeResult.before, writeResult.after);
|
|
126
|
-
} else {
|
|
127
|
-
console.log(`No changes for ${roadmapFile}`);
|
|
128
|
-
}
|
|
129
|
-
} else {
|
|
130
|
-
console.log(writeResult.changed ? `Updated ${roadmapFile}` : `No changes for ${roadmapFile}`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (isEnabled(flags.audit)) {
|
|
134
|
-
const parsedRoadmap = parseRoadmap(document);
|
|
135
|
-
const validationContext = buildValidationContext(projectRoot, config, plugins);
|
|
136
|
-
const results = validateTasks(parsedRoadmap.tasks, validationContext, config, plugins);
|
|
137
|
-
const audit = auditValidation(parsedRoadmap.tasks, results);
|
|
138
|
-
printAudit(audit);
|
|
139
|
-
}
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (command === 'sync') {
|
|
144
|
-
const projectRoot = path.resolve(String(flags['project-root'] || process.cwd()));
|
|
145
|
-
const config = loadConfig({ projectRoot, configPath: flags.config });
|
|
146
|
-
const roadmapFile = resolveRoadmapFile(projectRoot, config, flags['roadmap-file']);
|
|
147
|
-
const content = readTextIfExists(roadmapFile);
|
|
148
|
-
if (content == null) {
|
|
149
|
-
throw new Error(`Roadmap not found: ${roadmapFile}`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const parsedRoadmap = parseRoadmap(content);
|
|
153
|
-
const validationContext = buildValidationContext(projectRoot, config, loadPlugins(projectRoot, config.plugins));
|
|
154
|
-
const results = validateTasks(parsedRoadmap.tasks, validationContext, config, validationContext.plugins);
|
|
155
|
-
const next = applySync(content, parsedRoadmap.tasks, results);
|
|
156
|
-
const dryRun = isEnabled(flags['dry-run']);
|
|
157
|
-
const writeResult = writeText(roadmapFile, next, { dryRun });
|
|
158
|
-
|
|
159
|
-
if (dryRun) {
|
|
160
|
-
if (writeResult.changed) {
|
|
161
|
-
printDryRunDiff(roadmapFile, writeResult.before, writeResult.after);
|
|
162
|
-
} else {
|
|
163
|
-
console.log(`No changes for ${roadmapFile}`);
|
|
164
|
-
}
|
|
165
|
-
} else {
|
|
166
|
-
console.log(writeResult.changed ? `Updated ${roadmapFile}` : `No changes for ${roadmapFile}`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (isEnabled(flags.audit)) {
|
|
170
|
-
const audit = auditValidation(parsedRoadmap.tasks, results);
|
|
171
|
-
printAudit(audit);
|
|
172
|
-
}
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (command === 'validate') {
|
|
177
|
-
const projectRoot = path.resolve(String(flags['project-root'] || process.cwd()));
|
|
178
|
-
const config = loadConfig({ projectRoot, configPath: flags.config });
|
|
179
|
-
const roadmapFile = resolveRoadmapFile(projectRoot, config, flags['roadmap-file']);
|
|
180
|
-
const content = readTextIfExists(roadmapFile);
|
|
181
|
-
if (content == null) {
|
|
182
|
-
throw new Error(`Roadmap not found: ${roadmapFile}`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const parsedRoadmap = parseRoadmap(content);
|
|
186
|
-
const tasks = maybeFilterTasks(parsedRoadmap.tasks, flags.task);
|
|
187
|
-
const validationContext = buildValidationContext(projectRoot, config, loadPlugins(projectRoot, config.plugins));
|
|
188
|
-
const results = validateTasks(tasks, validationContext, config, validationContext.plugins);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { parseArgv } = require('../src/utils');
|
|
7
|
+
const { loadConfig, resolveRoadmapFile, resolveAgentsFile, loadPlugins } = require('../src/config');
|
|
8
|
+
const { readTextIfExists, writeText, printDryRunDiff } = require('../src/io');
|
|
9
|
+
const { renderRoadmapTemplate, renderAgentsTemplate } = require('../src/templates');
|
|
10
|
+
const { generateRoadmapDocument } = require('../src/generator');
|
|
11
|
+
const { parseRoadmap } = require('../src/parser');
|
|
12
|
+
const { buildValidationContext, validateTasks, auditValidation } = require('../src/validator');
|
|
13
|
+
const { applySync } = require('../src/sync');
|
|
14
|
+
|
|
15
|
+
function printHelp() {
|
|
16
|
+
console.log([
|
|
17
|
+
'Usage:',
|
|
18
|
+
' roadmapsmith init [--roadmap-file <path>] [--agents-file <path>] [--dry-run]',
|
|
19
|
+
' roadmapsmith generate [--project-root <path>] [--config <path>] [--roadmap-file <path>] [--dry-run] [--audit]',
|
|
20
|
+
' roadmapsmith sync [--roadmap-file <path>] [--project-root <path>] [--config <path>] [--dry-run] [--audit]',
|
|
21
|
+
' roadmapsmith validate [--roadmap-file <path>] [--project-root <path>] [--config <path>] [--task <id|text>] [--json]'
|
|
22
|
+
].join('\n'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isEnabled(value) {
|
|
26
|
+
if (value === true) return true;
|
|
27
|
+
if (typeof value !== 'string') return false;
|
|
28
|
+
const normalized = value.toLowerCase();
|
|
29
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatResultLine(task, result) {
|
|
33
|
+
const status = result.passed ? 'PASS' : 'FAIL';
|
|
34
|
+
const reason = result.reasons.length > 0 ? ` :: ${result.reasons.join('; ')}` : '';
|
|
35
|
+
return `${status} [${task.id}] ${task.text}${reason}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function maybeFilterTasks(tasks, filterValue) {
|
|
39
|
+
if (!filterValue) return tasks;
|
|
40
|
+
const normalized = String(filterValue).toLowerCase();
|
|
41
|
+
return tasks.filter((task) => {
|
|
42
|
+
return task.id.toLowerCase() === normalized || task.text.toLowerCase().includes(normalized);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function printAudit(audit) {
|
|
47
|
+
console.log(`Audit summary: ${audit.checkedWithoutEvidence.length} checked-without-evidence, ${audit.readyButUnchecked.length} ready-but-unchecked.`);
|
|
48
|
+
if (audit.checkedWithoutEvidence.length > 0) {
|
|
49
|
+
console.log('Checked without evidence:');
|
|
50
|
+
audit.checkedWithoutEvidence.forEach((item) => {
|
|
51
|
+
console.log(`- [${item.task.id}] ${item.task.text}`);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (audit.readyButUnchecked.length > 0) {
|
|
55
|
+
console.log('Ready but unchecked:');
|
|
56
|
+
audit.readyButUnchecked.forEach((item) => {
|
|
57
|
+
console.log(`- [${item.task.id}] ${item.task.text}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function run() {
|
|
63
|
+
const parsed = parseArgv(process.argv.slice(2));
|
|
64
|
+
const command = parsed.command;
|
|
65
|
+
const flags = parsed.flags;
|
|
66
|
+
|
|
67
|
+
if (!command || isEnabled(flags.help) || isEnabled(flags.h)) {
|
|
68
|
+
printHelp();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (command === 'init') {
|
|
73
|
+
const projectRoot = process.cwd();
|
|
74
|
+
const config = loadConfig({ projectRoot });
|
|
75
|
+
const roadmapFile = resolveRoadmapFile(projectRoot, config, flags['roadmap-file']);
|
|
76
|
+
const agentsFile = resolveAgentsFile(projectRoot, config, flags['agents-file']);
|
|
77
|
+
const dryRun = isEnabled(flags['dry-run']);
|
|
78
|
+
|
|
79
|
+
const roadmapExists = fs.existsSync(roadmapFile);
|
|
80
|
+
const agentsExists = fs.existsSync(agentsFile);
|
|
81
|
+
|
|
82
|
+
if (!roadmapExists) {
|
|
83
|
+
const roadmap = renderRoadmapTemplate();
|
|
84
|
+
const result = writeText(roadmapFile, roadmap, { dryRun });
|
|
85
|
+
if (dryRun && result.changed) {
|
|
86
|
+
printDryRunDiff(roadmapFile, result.before, result.after);
|
|
87
|
+
}
|
|
88
|
+
console.log(`${dryRun ? 'Would create' : 'Created'} ${roadmapFile}`);
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`Skipped existing ${roadmapFile}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!agentsExists) {
|
|
94
|
+
const agents = renderAgentsTemplate({ roadmapPath: path.basename(roadmapFile) });
|
|
95
|
+
const result = writeText(agentsFile, agents, { dryRun });
|
|
96
|
+
if (dryRun && result.changed) {
|
|
97
|
+
printDryRunDiff(agentsFile, result.before, result.after);
|
|
98
|
+
}
|
|
99
|
+
console.log(`${dryRun ? 'Would create' : 'Created'} ${agentsFile}`);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`Skipped existing ${agentsFile}`);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (command === 'generate') {
|
|
107
|
+
const projectRoot = path.resolve(String(flags['project-root'] || process.cwd()));
|
|
108
|
+
const config = loadConfig({ projectRoot, configPath: flags.config });
|
|
109
|
+
const roadmapFile = resolveRoadmapFile(projectRoot, config, flags['roadmap-file']);
|
|
110
|
+
const plugins = loadPlugins(projectRoot, config.plugins);
|
|
111
|
+
const existingContent = readTextIfExists(roadmapFile) || '';
|
|
112
|
+
const dryRun = isEnabled(flags['dry-run']);
|
|
113
|
+
|
|
114
|
+
const document = generateRoadmapDocument({
|
|
115
|
+
projectRoot,
|
|
116
|
+
roadmapPath: roadmapFile,
|
|
117
|
+
existingContent,
|
|
118
|
+
config,
|
|
119
|
+
plugins
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const writeResult = writeText(roadmapFile, document, { dryRun });
|
|
123
|
+
if (dryRun) {
|
|
124
|
+
if (writeResult.changed) {
|
|
125
|
+
printDryRunDiff(roadmapFile, writeResult.before, writeResult.after);
|
|
126
|
+
} else {
|
|
127
|
+
console.log(`No changes for ${roadmapFile}`);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
console.log(writeResult.changed ? `Updated ${roadmapFile}` : `No changes for ${roadmapFile}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isEnabled(flags.audit)) {
|
|
134
|
+
const parsedRoadmap = parseRoadmap(document);
|
|
135
|
+
const validationContext = buildValidationContext(projectRoot, config, plugins);
|
|
136
|
+
const results = validateTasks(parsedRoadmap.tasks, validationContext, config, plugins);
|
|
137
|
+
const audit = auditValidation(parsedRoadmap.tasks, results);
|
|
138
|
+
printAudit(audit);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (command === 'sync') {
|
|
144
|
+
const projectRoot = path.resolve(String(flags['project-root'] || process.cwd()));
|
|
145
|
+
const config = loadConfig({ projectRoot, configPath: flags.config });
|
|
146
|
+
const roadmapFile = resolveRoadmapFile(projectRoot, config, flags['roadmap-file']);
|
|
147
|
+
const content = readTextIfExists(roadmapFile);
|
|
148
|
+
if (content == null) {
|
|
149
|
+
throw new Error(`Roadmap not found: ${roadmapFile}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const parsedRoadmap = parseRoadmap(content);
|
|
153
|
+
const validationContext = buildValidationContext(projectRoot, config, loadPlugins(projectRoot, config.plugins));
|
|
154
|
+
const results = validateTasks(parsedRoadmap.tasks, validationContext, config, validationContext.plugins);
|
|
155
|
+
const next = applySync(content, parsedRoadmap.tasks, results);
|
|
156
|
+
const dryRun = isEnabled(flags['dry-run']);
|
|
157
|
+
const writeResult = writeText(roadmapFile, next, { dryRun });
|
|
158
|
+
|
|
159
|
+
if (dryRun) {
|
|
160
|
+
if (writeResult.changed) {
|
|
161
|
+
printDryRunDiff(roadmapFile, writeResult.before, writeResult.after);
|
|
162
|
+
} else {
|
|
163
|
+
console.log(`No changes for ${roadmapFile}`);
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
console.log(writeResult.changed ? `Updated ${roadmapFile}` : `No changes for ${roadmapFile}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (isEnabled(flags.audit)) {
|
|
170
|
+
const audit = auditValidation(parsedRoadmap.tasks, results);
|
|
171
|
+
printAudit(audit);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (command === 'validate') {
|
|
177
|
+
const projectRoot = path.resolve(String(flags['project-root'] || process.cwd()));
|
|
178
|
+
const config = loadConfig({ projectRoot, configPath: flags.config });
|
|
179
|
+
const roadmapFile = resolveRoadmapFile(projectRoot, config, flags['roadmap-file']);
|
|
180
|
+
const content = readTextIfExists(roadmapFile);
|
|
181
|
+
if (content == null) {
|
|
182
|
+
throw new Error(`Roadmap not found: ${roadmapFile}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const parsedRoadmap = parseRoadmap(content);
|
|
186
|
+
const tasks = maybeFilterTasks(parsedRoadmap.tasks, flags.task);
|
|
187
|
+
const validationContext = buildValidationContext(projectRoot, config, loadPlugins(projectRoot, config.plugins));
|
|
188
|
+
const results = validateTasks(tasks, validationContext, config, validationContext.plugins);
|
|
189
|
+
|
|
190
|
+
const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
191
|
+
const minRank = CONFIDENCE_RANK[config.validation && config.validation.minimumConfidence] ?? 0;
|
|
192
|
+
const visibleTasks = tasks.filter((task) => (CONFIDENCE_RANK[results[task.id].confidence] ?? 0) >= minRank);
|
|
193
|
+
|
|
194
|
+
if (isEnabled(flags.json)) {
|
|
195
|
+
const payload = visibleTasks.map((task) => ({ task, result: results[task.id] }));
|
|
196
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
197
|
+
} else {
|
|
198
|
+
visibleTasks.forEach((task) => {
|
|
199
|
+
console.log(formatResultLine(task, results[task.id]));
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const failed = visibleTasks.some((task) => !results[task.id].passed);
|
|
204
|
+
if (failed) {
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
throw new Error(`Unknown command: ${command}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
run().catch((error) => {
|
|
214
|
+
console.error(error.message);
|
|
215
|
+
process.exitCode = 1;
|
|
212
216
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roadmapsmith",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Generate, sync, and validate deterministic project roadmaps for agent-driven execution.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -12,10 +12,18 @@
|
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
14
|
"roadmap",
|
|
15
|
-
"
|
|
15
|
+
"planning",
|
|
16
16
|
"agent",
|
|
17
17
|
"cli",
|
|
18
|
-
"
|
|
18
|
+
"validation",
|
|
19
|
+
"sync",
|
|
20
|
+
"task-tracking",
|
|
21
|
+
"evidence-based",
|
|
22
|
+
"deterministic",
|
|
23
|
+
"monorepo",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"ai-agent",
|
|
26
|
+
"project-management"
|
|
19
27
|
],
|
|
20
28
|
"author": "PapiScholz",
|
|
21
29
|
"license": "MIT",
|
package/src/config.js
CHANGED
|
@@ -24,6 +24,9 @@ const DEFAULT_CONFIG = {
|
|
|
24
24
|
steps: [],
|
|
25
25
|
phases: []
|
|
26
26
|
},
|
|
27
|
+
validation: {
|
|
28
|
+
minimumConfidence: 'low'
|
|
29
|
+
},
|
|
27
30
|
milestones: [
|
|
28
31
|
{ version: 'v0.1', goal: 'Foundation baseline complete' },
|
|
29
32
|
{ version: 'v0.2', goal: 'Core feature coverage stabilized' },
|
|
@@ -73,6 +76,10 @@ function mergeConfig(userConfig) {
|
|
|
73
76
|
phases: (userConfig && userConfig.product && Array.isArray(userConfig.product.phases))
|
|
74
77
|
? userConfig.product.phases
|
|
75
78
|
: DEFAULT_CONFIG.product.phases
|
|
79
|
+
},
|
|
80
|
+
validation: {
|
|
81
|
+
...DEFAULT_CONFIG.validation,
|
|
82
|
+
...((userConfig && userConfig.validation) || {})
|
|
76
83
|
}
|
|
77
84
|
};
|
|
78
85
|
}
|
|
@@ -176,8 +176,10 @@ function renderSection5Milestones(model, lines) {
|
|
|
176
176
|
lines.push('**What Must Be Stable:**');
|
|
177
177
|
lines.push('');
|
|
178
178
|
for (const item of milestone.mustBeStable) {
|
|
179
|
-
const
|
|
180
|
-
|
|
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} -->`);
|
|
181
183
|
}
|
|
182
184
|
lines.push('');
|
|
183
185
|
}
|
package/src/validator/index.js
CHANGED
|
@@ -1,420 +1,425 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const { walkFiles, detectTestFrameworks } = require('../io');
|
|
6
|
-
const { collectPluginContributions } = require('../config');
|
|
7
|
-
const { escapeRegExp, tokenize } = require('../utils');
|
|
8
|
-
|
|
9
|
-
const CODE_EXTENSIONS = new Set([
|
|
10
|
-
'.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb', '.php', '.cs'
|
|
11
|
-
]);
|
|
12
|
-
|
|
13
|
-
const DOC_HINTS = ['readme', 'changelog', 'docs', 'documentation', 'spec', 'diagram', 'runbook'];
|
|
14
|
-
const CODE_HINTS = ['implement', 'add', 'create', 'build', 'refactor', 'fix', 'module', 'function', 'api', 'endpoint', 'command'];
|
|
15
|
-
const GENERIC_TASK_TOKENS = new Set([
|
|
16
|
-
'implement',
|
|
17
|
-
'implementation',
|
|
18
|
-
'module',
|
|
19
|
-
'function',
|
|
20
|
-
'class',
|
|
21
|
-
'method',
|
|
22
|
-
'command',
|
|
23
|
-
'create',
|
|
24
|
-
'add',
|
|
25
|
-
'build',
|
|
26
|
-
'refactor',
|
|
27
|
-
'fix',
|
|
28
|
-
'test',
|
|
29
|
-
'tests'
|
|
30
|
-
]);
|
|
31
|
-
|
|
32
|
-
function readFileIndex(projectRoot, files) {
|
|
33
|
-
const index = [];
|
|
34
|
-
for (const relativePath of files) {
|
|
35
|
-
const absolutePath = path.resolve(projectRoot, relativePath);
|
|
36
|
-
const ext = path.extname(relativePath).toLowerCase();
|
|
37
|
-
let content = '';
|
|
38
|
-
try {
|
|
39
|
-
const buffer = fs.readFileSync(absolutePath);
|
|
40
|
-
if (buffer.length > 512 * 1024) {
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
content = buffer.toString('utf8');
|
|
44
|
-
} catch {
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
index.push({
|
|
49
|
-
relativePath,
|
|
50
|
-
absolutePath,
|
|
51
|
-
ext,
|
|
52
|
-
content,
|
|
53
|
-
isTestFile: /(^|\/)(__tests__|tests)\//.test(relativePath) || /\.test\.|\.spec\.|_test\.go$/.test(relativePath)
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
return index;
|
|
57
|
-
}
|
|
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
|
-
|
|
77
|
-
function extractExplicitPaths(text) {
|
|
78
|
-
const results = new Set();
|
|
79
|
-
const quoted = String(text).match(/`([^`]+)`/g) || [];
|
|
80
|
-
for (const token of quoted) {
|
|
81
|
-
const clean = token.slice(1, -1);
|
|
82
|
-
if (clean.includes('/') || clean.includes('\\') || clean.includes('.')) {
|
|
83
|
-
results.add(clean);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const pathTokens = String(text).match(/([A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+)/g) || [];
|
|
88
|
-
for (const raw of pathTokens) {
|
|
89
|
-
const token = raw.replace(/[.,;:!?)]+$/, '');
|
|
90
|
-
if (isLikelyPath(token)) results.add(token);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return Array.from(results).sort((left, right) => left.localeCompare(right));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function extractSymbolHints(text) {
|
|
97
|
-
const symbols = new Set();
|
|
98
|
-
const patterns = [
|
|
99
|
-
/(?:function|class|method|command)\s+([A-Za-z_][A-Za-z0-9_]*)/gi,
|
|
100
|
-
/(?:function|module|class|command|method)\s+`([A-Za-z_][A-Za-z0-9_-]*)`/gi,
|
|
101
|
-
/`([A-Za-z_][A-Za-z0-9_-]*)`\s+(?:function|module|class|command|method)/gi
|
|
102
|
-
];
|
|
103
|
-
|
|
104
|
-
for (const pattern of patterns) {
|
|
105
|
-
let match = pattern.exec(text);
|
|
106
|
-
while (match) {
|
|
107
|
-
symbols.add(match[1]);
|
|
108
|
-
match = pattern.exec(text);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return Array.from(symbols).sort((left, right) => left.localeCompare(right));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function isCodeTask(taskText) {
|
|
116
|
-
const normalized = String(taskText).toLowerCase();
|
|
117
|
-
return CODE_HINTS.some((hint) => normalized.includes(hint));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function isDocTask(taskText) {
|
|
121
|
-
const normalized = String(taskText).toLowerCase();
|
|
122
|
-
return DOC_HINTS.some((hint) => normalized.includes(hint));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function findFilesByPathHints(pathHints, fileIndex) {
|
|
126
|
-
const matches = [];
|
|
127
|
-
for (const hint of pathHints) {
|
|
128
|
-
const normalizedHint = hint.replace(/\\/g, '/');
|
|
129
|
-
const direct = fileIndex.find((file) => file.relativePath === normalizedHint);
|
|
130
|
-
if (direct) {
|
|
131
|
-
matches.push(direct.relativePath);
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
for (const file of fileIndex) {
|
|
136
|
-
if (file.relativePath.endsWith(normalizedHint)) {
|
|
137
|
-
matches.push(file.relativePath);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return Array.from(new Set(matches)).sort((left, right) => left.localeCompare(right));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function findFilesBySymbols(symbolHints, fileIndex) {
|
|
145
|
-
const matches = new Set();
|
|
146
|
-
for (const symbol of symbolHints) {
|
|
147
|
-
const regex = new RegExp(`\\b${escapeRegExp(symbol)}\\b`, 'i');
|
|
148
|
-
for (const file of fileIndex) {
|
|
149
|
-
if (!CODE_EXTENSIONS.has(file.ext)) {
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
if (regex.test(file.content)) {
|
|
153
|
-
matches.add(file.relativePath);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return Array.from(matches).sort((left, right) => left.localeCompare(right));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function findCodeEvidence(taskText, fileIndex) {
|
|
161
|
-
const tokens = tokenize(taskText)
|
|
162
|
-
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token))
|
|
163
|
-
.slice(0, 8);
|
|
164
|
-
if (tokens.length === 0) {
|
|
165
|
-
return [];
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const matches = [];
|
|
169
|
-
for (const file of fileIndex) {
|
|
170
|
-
if (!CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
let score = 0;
|
|
175
|
-
const lowered = file.content.toLowerCase();
|
|
176
|
-
for (const token of tokens) {
|
|
177
|
-
if (token.length < 3) {
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
if (lowered.includes(token)) {
|
|
181
|
-
score += 1;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const threshold = tokens.length === 1 ? 1 : 2;
|
|
186
|
-
if (score >= threshold) {
|
|
187
|
-
matches.push(file.relativePath);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return matches.slice(0, 20);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function findTestEvidence(taskText, fileIndex) {
|
|
195
|
-
const tokens = tokenize(taskText)
|
|
196
|
-
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token))
|
|
197
|
-
.slice(0, 8);
|
|
198
|
-
const matches = [];
|
|
199
|
-
|
|
200
|
-
for (const file of fileIndex) {
|
|
201
|
-
if (!file.isTestFile) {
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
const lowered = file.content.toLowerCase();
|
|
205
|
-
const hasMatch = tokens.some((token) => lowered.includes(token));
|
|
206
|
-
if (hasMatch) {
|
|
207
|
-
matches.push(file.relativePath);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return matches.slice(0, 20);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function findArtifactEvidence(taskText, fileIndex) {
|
|
215
|
-
const normalized = String(taskText).toLowerCase();
|
|
216
|
-
const matches = [];
|
|
217
|
-
|
|
218
|
-
if (!isDocTask(taskText) && !normalized.includes('artifact') && !normalized.includes('release')) {
|
|
219
|
-
return matches;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const artifactPatterns = [
|
|
223
|
-
/^README\.md$/i,
|
|
224
|
-
/^CHANGELOG\.md$/i,
|
|
225
|
-
/^docs\//i,
|
|
226
|
-
/^artifacts\//i,
|
|
227
|
-
/^dist\//i,
|
|
228
|
-
/^build\//i
|
|
229
|
-
];
|
|
230
|
-
|
|
231
|
-
for (const file of fileIndex) {
|
|
232
|
-
if (artifactPatterns.some((pattern) => pattern.test(file.relativePath))) {
|
|
233
|
-
matches.push(file.relativePath);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return matches.slice(0, 20);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function evaluateRule(rule, task, context) {
|
|
241
|
-
if (!rule) {
|
|
242
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (rule.when) {
|
|
246
|
-
const regexp = new RegExp(rule.when, 'i');
|
|
247
|
-
if (!regexp.test(task.text)) {
|
|
248
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (typeof rule.check === 'function') {
|
|
253
|
-
const custom = rule.check(task, context);
|
|
254
|
-
if (!custom) {
|
|
255
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
256
|
-
}
|
|
257
|
-
return {
|
|
258
|
-
passed: custom.passed !== false,
|
|
259
|
-
reasons: Array.isArray(custom.reasons) ? custom.reasons : [],
|
|
260
|
-
evidence: custom.evidence || {}
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const reasons = [];
|
|
265
|
-
const evidence = {};
|
|
266
|
-
|
|
267
|
-
if (rule.type === 'file-exists' && rule.path) {
|
|
268
|
-
const hit = context.fileIndex.find((file) => file.relativePath === rule.path || file.relativePath.endsWith(rule.path));
|
|
269
|
-
if (!hit) {
|
|
270
|
-
reasons.push(rule.message || `missing file: ${rule.path}`);
|
|
271
|
-
} else {
|
|
272
|
-
evidence.file = hit.relativePath;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (rule.type === 'symbol' && rule.pattern) {
|
|
277
|
-
const regex = new RegExp(rule.pattern, 'i');
|
|
278
|
-
const hit = context.fileIndex.find((file) => regex.test(file.content));
|
|
279
|
-
if (!hit) {
|
|
280
|
-
reasons.push(rule.message || `missing symbol pattern: ${rule.pattern}`);
|
|
281
|
-
} else {
|
|
282
|
-
evidence.symbol = hit.relativePath;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
if (rule.type === 'artifact' && rule.path) {
|
|
287
|
-
const hit = context.fileIndex.find((file) => file.relativePath.startsWith(rule.path) || file.relativePath === rule.path);
|
|
288
|
-
if (!hit) {
|
|
289
|
-
reasons.push(rule.message || `missing artifact: ${rule.path}`);
|
|
290
|
-
} else {
|
|
291
|
-
evidence.artifact = hit.relativePath;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (rule.type === 'test' && context.testFrameworks.length === 0) {
|
|
296
|
-
reasons.push(rule.message || 'test framework not detected');
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return {
|
|
300
|
-
passed: reasons.length === 0,
|
|
301
|
-
reasons,
|
|
302
|
-
evidence
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function buildValidationContext(projectRoot, config, plugins) {
|
|
307
|
-
const files = walkFiles(projectRoot);
|
|
308
|
-
const fileIndex = readFileIndex(projectRoot, files);
|
|
309
|
-
const testFrameworks = detectTestFrameworks(projectRoot, files);
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
projectRoot,
|
|
313
|
-
config,
|
|
314
|
-
plugins,
|
|
315
|
-
files,
|
|
316
|
-
fileIndex,
|
|
317
|
-
testFrameworks
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function validateTask(task, context, config, plugins) {
|
|
322
|
-
const pathHints = extractExplicitPaths(task.text);
|
|
323
|
-
const symbolHints = extractSymbolHints(task.text);
|
|
324
|
-
|
|
325
|
-
const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
|
|
326
|
-
const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
|
|
327
|
-
const filesFromCode = findCodeEvidence(task.text, context.fileIndex);
|
|
328
|
-
const filesFromTests = findTestEvidence(task.text, context.fileIndex);
|
|
329
|
-
const filesFromArtifacts = findArtifactEvidence(task.text, context.fileIndex);
|
|
330
|
-
|
|
331
|
-
const evidence = {
|
|
332
|
-
code: filesFromCode.length > 0 || filesFromSymbols.length > 0,
|
|
333
|
-
test: filesFromTests.length > 0,
|
|
334
|
-
artifact: filesFromArtifacts.length > 0,
|
|
335
|
-
files: filesFromPaths,
|
|
336
|
-
symbols: filesFromSymbols,
|
|
337
|
-
codeFiles: filesFromCode,
|
|
338
|
-
testFiles: filesFromTests,
|
|
339
|
-
artifactFiles: filesFromArtifacts
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
const reasons = [];
|
|
343
|
-
if (pathHints.length > 0 && filesFromPaths.length === 0) {
|
|
344
|
-
reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
|
|
345
|
-
}
|
|
346
|
-
if (symbolHints.length > 0 && filesFromSymbols.length === 0) {
|
|
347
|
-
reasons.push(`missing symbol(s): ${symbolHints.join(', ')}`);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
|
|
351
|
-
if (!hasEvidence) {
|
|
352
|
-
reasons.push('no code, test, or artifact evidence found');
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const requiresTest = context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
|
|
356
|
-
if (requiresTest && !evidence.test) {
|
|
357
|
-
reasons.push('missing test evidence');
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const configuredRules = Array.isArray(config.validators) ? config.validators : [];
|
|
361
|
-
const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
|
|
362
|
-
for (const rule of [...configuredRules, ...pluginRules]) {
|
|
363
|
-
const ruleResult = evaluateRule(rule, task, context);
|
|
364
|
-
if (!ruleResult.passed) {
|
|
365
|
-
reasons.push(...ruleResult.reasons);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const uniqueReasons = Array.from(new Set(reasons));
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { walkFiles, detectTestFrameworks } = require('../io');
|
|
6
|
+
const { collectPluginContributions } = require('../config');
|
|
7
|
+
const { escapeRegExp, tokenize } = require('../utils');
|
|
8
|
+
|
|
9
|
+
const CODE_EXTENSIONS = new Set([
|
|
10
|
+
'.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb', '.php', '.cs'
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const DOC_HINTS = ['readme', 'changelog', 'docs', 'documentation', 'spec', 'diagram', 'runbook'];
|
|
14
|
+
const CODE_HINTS = ['implement', 'add', 'create', 'build', 'refactor', 'fix', 'module', 'function', 'api', 'endpoint', 'command'];
|
|
15
|
+
const GENERIC_TASK_TOKENS = new Set([
|
|
16
|
+
'implement',
|
|
17
|
+
'implementation',
|
|
18
|
+
'module',
|
|
19
|
+
'function',
|
|
20
|
+
'class',
|
|
21
|
+
'method',
|
|
22
|
+
'command',
|
|
23
|
+
'create',
|
|
24
|
+
'add',
|
|
25
|
+
'build',
|
|
26
|
+
'refactor',
|
|
27
|
+
'fix',
|
|
28
|
+
'test',
|
|
29
|
+
'tests'
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function readFileIndex(projectRoot, files) {
|
|
33
|
+
const index = [];
|
|
34
|
+
for (const relativePath of files) {
|
|
35
|
+
const absolutePath = path.resolve(projectRoot, relativePath);
|
|
36
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
37
|
+
let content = '';
|
|
38
|
+
try {
|
|
39
|
+
const buffer = fs.readFileSync(absolutePath);
|
|
40
|
+
if (buffer.length > 512 * 1024) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
content = buffer.toString('utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
index.push({
|
|
49
|
+
relativePath,
|
|
50
|
+
absolutePath,
|
|
51
|
+
ext,
|
|
52
|
+
content,
|
|
53
|
+
isTestFile: /(^|\/)(__tests__|tests)\//.test(relativePath) || /\.test\.|\.spec\.|_test\.go$/.test(relativePath)
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return index;
|
|
57
|
+
}
|
|
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
|
+
|
|
77
|
+
function extractExplicitPaths(text) {
|
|
78
|
+
const results = new Set();
|
|
79
|
+
const quoted = String(text).match(/`([^`]+)`/g) || [];
|
|
80
|
+
for (const token of quoted) {
|
|
81
|
+
const clean = token.slice(1, -1);
|
|
82
|
+
if (clean.includes('/') || clean.includes('\\') || clean.includes('.')) {
|
|
83
|
+
results.add(clean);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pathTokens = String(text).match(/([A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+)/g) || [];
|
|
88
|
+
for (const raw of pathTokens) {
|
|
89
|
+
const token = raw.replace(/[.,;:!?)]+$/, '');
|
|
90
|
+
if (isLikelyPath(token)) results.add(token);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Array.from(results).sort((left, right) => left.localeCompare(right));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractSymbolHints(text) {
|
|
97
|
+
const symbols = new Set();
|
|
98
|
+
const patterns = [
|
|
99
|
+
/(?:function|class|method|command)\s+([A-Za-z_][A-Za-z0-9_]*)/gi,
|
|
100
|
+
/(?:function|module|class|command|method)\s+`([A-Za-z_][A-Za-z0-9_-]*)`/gi,
|
|
101
|
+
/`([A-Za-z_][A-Za-z0-9_-]*)`\s+(?:function|module|class|command|method)/gi
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
for (const pattern of patterns) {
|
|
105
|
+
let match = pattern.exec(text);
|
|
106
|
+
while (match) {
|
|
107
|
+
symbols.add(match[1]);
|
|
108
|
+
match = pattern.exec(text);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return Array.from(symbols).sort((left, right) => left.localeCompare(right));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isCodeTask(taskText) {
|
|
116
|
+
const normalized = String(taskText).toLowerCase();
|
|
117
|
+
return CODE_HINTS.some((hint) => normalized.includes(hint));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isDocTask(taskText) {
|
|
121
|
+
const normalized = String(taskText).toLowerCase();
|
|
122
|
+
return DOC_HINTS.some((hint) => normalized.includes(hint));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findFilesByPathHints(pathHints, fileIndex) {
|
|
126
|
+
const matches = [];
|
|
127
|
+
for (const hint of pathHints) {
|
|
128
|
+
const normalizedHint = hint.replace(/\\/g, '/');
|
|
129
|
+
const direct = fileIndex.find((file) => file.relativePath === normalizedHint);
|
|
130
|
+
if (direct) {
|
|
131
|
+
matches.push(direct.relativePath);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const file of fileIndex) {
|
|
136
|
+
if (file.relativePath.endsWith(normalizedHint)) {
|
|
137
|
+
matches.push(file.relativePath);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return Array.from(new Set(matches)).sort((left, right) => left.localeCompare(right));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findFilesBySymbols(symbolHints, fileIndex) {
|
|
145
|
+
const matches = new Set();
|
|
146
|
+
for (const symbol of symbolHints) {
|
|
147
|
+
const regex = new RegExp(`\\b${escapeRegExp(symbol)}\\b`, 'i');
|
|
148
|
+
for (const file of fileIndex) {
|
|
149
|
+
if (!CODE_EXTENSIONS.has(file.ext)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (regex.test(file.content)) {
|
|
153
|
+
matches.add(file.relativePath);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return Array.from(matches).sort((left, right) => left.localeCompare(right));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function findCodeEvidence(taskText, fileIndex) {
|
|
161
|
+
const tokens = tokenize(taskText)
|
|
162
|
+
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token))
|
|
163
|
+
.slice(0, 8);
|
|
164
|
+
if (tokens.length === 0) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const matches = [];
|
|
169
|
+
for (const file of fileIndex) {
|
|
170
|
+
if (!CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let score = 0;
|
|
175
|
+
const lowered = file.content.toLowerCase();
|
|
176
|
+
for (const token of tokens) {
|
|
177
|
+
if (token.length < 3) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (lowered.includes(token)) {
|
|
181
|
+
score += 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const threshold = tokens.length === 1 ? 1 : 2;
|
|
186
|
+
if (score >= threshold) {
|
|
187
|
+
matches.push(file.relativePath);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return matches.slice(0, 20);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function findTestEvidence(taskText, fileIndex) {
|
|
195
|
+
const tokens = tokenize(taskText)
|
|
196
|
+
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token))
|
|
197
|
+
.slice(0, 8);
|
|
198
|
+
const matches = [];
|
|
199
|
+
|
|
200
|
+
for (const file of fileIndex) {
|
|
201
|
+
if (!file.isTestFile) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const lowered = file.content.toLowerCase();
|
|
205
|
+
const hasMatch = tokens.some((token) => lowered.includes(token));
|
|
206
|
+
if (hasMatch) {
|
|
207
|
+
matches.push(file.relativePath);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return matches.slice(0, 20);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function findArtifactEvidence(taskText, fileIndex) {
|
|
215
|
+
const normalized = String(taskText).toLowerCase();
|
|
216
|
+
const matches = [];
|
|
217
|
+
|
|
218
|
+
if (!isDocTask(taskText) && !normalized.includes('artifact') && !normalized.includes('release')) {
|
|
219
|
+
return matches;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const artifactPatterns = [
|
|
223
|
+
/^README\.md$/i,
|
|
224
|
+
/^CHANGELOG\.md$/i,
|
|
225
|
+
/^docs\//i,
|
|
226
|
+
/^artifacts\//i,
|
|
227
|
+
/^dist\//i,
|
|
228
|
+
/^build\//i
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
for (const file of fileIndex) {
|
|
232
|
+
if (artifactPatterns.some((pattern) => pattern.test(file.relativePath))) {
|
|
233
|
+
matches.push(file.relativePath);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return matches.slice(0, 20);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function evaluateRule(rule, task, context) {
|
|
241
|
+
if (!rule) {
|
|
242
|
+
return { passed: true, reasons: [], evidence: {} };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (rule.when) {
|
|
246
|
+
const regexp = new RegExp(rule.when, 'i');
|
|
247
|
+
if (!regexp.test(task.text)) {
|
|
248
|
+
return { passed: true, reasons: [], evidence: {} };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (typeof rule.check === 'function') {
|
|
253
|
+
const custom = rule.check(task, context);
|
|
254
|
+
if (!custom) {
|
|
255
|
+
return { passed: true, reasons: [], evidence: {} };
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
passed: custom.passed !== false,
|
|
259
|
+
reasons: Array.isArray(custom.reasons) ? custom.reasons : [],
|
|
260
|
+
evidence: custom.evidence || {}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const reasons = [];
|
|
265
|
+
const evidence = {};
|
|
266
|
+
|
|
267
|
+
if (rule.type === 'file-exists' && rule.path) {
|
|
268
|
+
const hit = context.fileIndex.find((file) => file.relativePath === rule.path || file.relativePath.endsWith(rule.path));
|
|
269
|
+
if (!hit) {
|
|
270
|
+
reasons.push(rule.message || `missing file: ${rule.path}`);
|
|
271
|
+
} else {
|
|
272
|
+
evidence.file = hit.relativePath;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (rule.type === 'symbol' && rule.pattern) {
|
|
277
|
+
const regex = new RegExp(rule.pattern, 'i');
|
|
278
|
+
const hit = context.fileIndex.find((file) => regex.test(file.content));
|
|
279
|
+
if (!hit) {
|
|
280
|
+
reasons.push(rule.message || `missing symbol pattern: ${rule.pattern}`);
|
|
281
|
+
} else {
|
|
282
|
+
evidence.symbol = hit.relativePath;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (rule.type === 'artifact' && rule.path) {
|
|
287
|
+
const hit = context.fileIndex.find((file) => file.relativePath.startsWith(rule.path) || file.relativePath === rule.path);
|
|
288
|
+
if (!hit) {
|
|
289
|
+
reasons.push(rule.message || `missing artifact: ${rule.path}`);
|
|
290
|
+
} else {
|
|
291
|
+
evidence.artifact = hit.relativePath;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (rule.type === 'test' && context.testFrameworks.length === 0) {
|
|
296
|
+
reasons.push(rule.message || 'test framework not detected');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
passed: reasons.length === 0,
|
|
301
|
+
reasons,
|
|
302
|
+
evidence
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function buildValidationContext(projectRoot, config, plugins) {
|
|
307
|
+
const files = walkFiles(projectRoot);
|
|
308
|
+
const fileIndex = readFileIndex(projectRoot, files);
|
|
309
|
+
const testFrameworks = detectTestFrameworks(projectRoot, files);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
projectRoot,
|
|
313
|
+
config,
|
|
314
|
+
plugins,
|
|
315
|
+
files,
|
|
316
|
+
fileIndex,
|
|
317
|
+
testFrameworks
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateTask(task, context, config, plugins) {
|
|
322
|
+
const pathHints = extractExplicitPaths(task.text);
|
|
323
|
+
const symbolHints = extractSymbolHints(task.text);
|
|
324
|
+
|
|
325
|
+
const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
|
|
326
|
+
const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
|
|
327
|
+
const filesFromCode = findCodeEvidence(task.text, context.fileIndex);
|
|
328
|
+
const filesFromTests = findTestEvidence(task.text, context.fileIndex);
|
|
329
|
+
const filesFromArtifacts = findArtifactEvidence(task.text, context.fileIndex);
|
|
330
|
+
|
|
331
|
+
const evidence = {
|
|
332
|
+
code: filesFromCode.length > 0 || filesFromSymbols.length > 0,
|
|
333
|
+
test: filesFromTests.length > 0,
|
|
334
|
+
artifact: filesFromArtifacts.length > 0,
|
|
335
|
+
files: filesFromPaths,
|
|
336
|
+
symbols: filesFromSymbols,
|
|
337
|
+
codeFiles: filesFromCode,
|
|
338
|
+
testFiles: filesFromTests,
|
|
339
|
+
artifactFiles: filesFromArtifacts
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const reasons = [];
|
|
343
|
+
if (pathHints.length > 0 && filesFromPaths.length === 0) {
|
|
344
|
+
reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
|
|
345
|
+
}
|
|
346
|
+
if (symbolHints.length > 0 && filesFromSymbols.length === 0) {
|
|
347
|
+
reasons.push(`missing symbol(s): ${symbolHints.join(', ')}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
|
|
351
|
+
if (!hasEvidence) {
|
|
352
|
+
reasons.push('no code, test, or artifact evidence found');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const requiresTest = context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
|
|
356
|
+
if (requiresTest && !evidence.test) {
|
|
357
|
+
reasons.push('missing test evidence');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const configuredRules = Array.isArray(config.validators) ? config.validators : [];
|
|
361
|
+
const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
|
|
362
|
+
for (const rule of [...configuredRules, ...pluginRules]) {
|
|
363
|
+
const ruleResult = evaluateRule(rule, task, context);
|
|
364
|
+
if (!ruleResult.passed) {
|
|
365
|
+
reasons.push(...ruleResult.reasons);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const uniqueReasons = Array.from(new Set(reasons));
|
|
370
|
+
const attempted = hasEvidence || pathHints.length > 0 || symbolHints.length > 0;
|
|
371
|
+
|
|
372
|
+
const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
|
|
373
|
+
const confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : attempted ? 'medium' : 'low';
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
taskId: task.id,
|
|
377
|
+
passed: uniqueReasons.length === 0,
|
|
378
|
+
confidence,
|
|
379
|
+
reasons: uniqueReasons,
|
|
380
|
+
evidence,
|
|
381
|
+
requiresTest,
|
|
382
|
+
hasEvidence,
|
|
383
|
+
attempted
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validateTasks(tasks, context, config, plugins) {
|
|
388
|
+
const result = {};
|
|
389
|
+
for (const task of tasks) {
|
|
390
|
+
result[task.id] = validateTask(task, context, config, plugins);
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function auditValidation(tasks, results) {
|
|
396
|
+
const checkedWithoutEvidence = [];
|
|
397
|
+
const readyButUnchecked = [];
|
|
398
|
+
|
|
399
|
+
for (const task of tasks) {
|
|
400
|
+
const result = results[task.id];
|
|
401
|
+
if (!result) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (task.checked && !result.passed) {
|
|
406
|
+
checkedWithoutEvidence.push({ task, result });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!task.checked && result.passed) {
|
|
410
|
+
readyButUnchecked.push({ task, result });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
checkedWithoutEvidence,
|
|
416
|
+
readyButUnchecked
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
module.exports = {
|
|
421
|
+
auditValidation,
|
|
422
|
+
buildValidationContext,
|
|
423
|
+
validateTask,
|
|
424
|
+
validateTasks
|
|
425
|
+
};
|