roadmapsmith 0.7.0 → 0.8.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/package.json +1 -1
- package/src/classifier/index.js +134 -0
- package/src/generator/index.js +53 -2
- package/src/renderer/professional.js +8 -0
- package/src/validator/index.js +309 -43
package/package.json
CHANGED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const WEB_DIRS = ['app/', 'pages/', 'components/', 'src/app/', 'src/pages/', 'src/components/'];
|
|
7
|
+
const ASSET_DIRS = ['public/', 'assets/', 'static/'];
|
|
8
|
+
const WEB_CONFIGS = [
|
|
9
|
+
'next.config.js', 'next.config.ts', 'next.config.mjs',
|
|
10
|
+
'vite.config.js', 'vite.config.ts',
|
|
11
|
+
'astro.config.mjs', 'astro.config.ts'
|
|
12
|
+
];
|
|
13
|
+
const STYLE_CONFIGS = ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs'];
|
|
14
|
+
const WEB_DEPS = new Set(['next', 'react', 'vue', 'svelte', 'astro', 'vite', 'nuxt', 'gatsby', 'remix', '@remix-run/react']);
|
|
15
|
+
const LANDING_ROUTE_RE = /(?:^|\/)(?:contact|services|about|pricing|hero|cta|landing)(?:\/|\.)/i;
|
|
16
|
+
|
|
17
|
+
function readPackageDeps(projectRoot) {
|
|
18
|
+
if (!projectRoot) return [];
|
|
19
|
+
try {
|
|
20
|
+
const raw = fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8');
|
|
21
|
+
const pkg = JSON.parse(raw);
|
|
22
|
+
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasDir(files, prefix) {
|
|
29
|
+
return files.some((f) => f.startsWith(prefix));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasFilename(files, name) {
|
|
33
|
+
return files.some((f) => f === name || f.endsWith('/' + name));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasWorkspaces(projectRoot) {
|
|
37
|
+
if (!projectRoot) return false;
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
|
40
|
+
return Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function classifyProject({ projectRoot, files }) {
|
|
47
|
+
const signals = [];
|
|
48
|
+
|
|
49
|
+
if (hasWorkspaces(projectRoot)) {
|
|
50
|
+
signals.push('package.json workspaces field');
|
|
51
|
+
return { type: 'monorepo', confidence: 'high', signals };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hasPy = hasFilename(files, 'pyproject.toml') || hasFilename(files, 'setup.py');
|
|
55
|
+
if (hasPy && !files.some((f) => /\.[jt]sx?$/.test(f))) {
|
|
56
|
+
signals.push('pyproject.toml / setup.py, no JS files');
|
|
57
|
+
return { type: 'python-package', confidence: 'high', signals };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let webScore = 0;
|
|
61
|
+
let landingScore = 0;
|
|
62
|
+
const deps = readPackageDeps(projectRoot);
|
|
63
|
+
|
|
64
|
+
for (const dep of deps) {
|
|
65
|
+
if (WEB_DEPS.has(dep)) {
|
|
66
|
+
webScore += 2;
|
|
67
|
+
signals.push(`dependency: ${dep}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const dir of WEB_DIRS) {
|
|
72
|
+
if (hasDir(files, dir)) {
|
|
73
|
+
webScore += 2;
|
|
74
|
+
signals.push(`directory: ${dir.replace(/\/$/, '')}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const dir of ASSET_DIRS) {
|
|
79
|
+
if (hasDir(files, dir)) {
|
|
80
|
+
webScore += 1;
|
|
81
|
+
signals.push(`directory: ${dir.replace(/\/$/, '')}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const cfg of WEB_CONFIGS) {
|
|
86
|
+
if (hasFilename(files, cfg)) {
|
|
87
|
+
webScore += 3;
|
|
88
|
+
signals.push(`config: ${cfg}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const cfg of STYLE_CONFIGS) {
|
|
93
|
+
if (hasFilename(files, cfg)) {
|
|
94
|
+
webScore += 1;
|
|
95
|
+
signals.push(`config: ${cfg}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (files.some((f) => /\.css$/.test(f))) {
|
|
100
|
+
webScore += 1;
|
|
101
|
+
signals.push('CSS files present');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const landingRoutes = files.filter((f) => LANDING_ROUTE_RE.test(f));
|
|
105
|
+
if (landingRoutes.length > 0) {
|
|
106
|
+
landingScore += landingRoutes.length * 2;
|
|
107
|
+
signals.push(`landing/service routes: ${landingRoutes.length}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (hasFilename(files, 'favicon.ico') || hasFilename(files, 'logo.png') || hasFilename(files, 'logo.svg')) {
|
|
111
|
+
landingScore += 1;
|
|
112
|
+
signals.push('branding asset in public/');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (webScore === 0 && (files.some((f) => f.startsWith('bin/')) || hasFilename(files, 'cli.js'))) {
|
|
116
|
+
signals.push('bin/ directory or cli.js');
|
|
117
|
+
return { type: 'cli-tool', confidence: 'medium', signals };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (webScore === 0 && hasFilename(files, 'package.json')) {
|
|
121
|
+
signals.push('package.json, no web signals');
|
|
122
|
+
return { type: 'npm-package', confidence: 'low', signals };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (webScore >= 3) {
|
|
126
|
+
const type = landingScore >= 3 ? 'landing-site' : 'frontend-web';
|
|
127
|
+
const confidence = webScore >= 7 ? 'high' : 'medium';
|
|
128
|
+
return { type, confidence, signals };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { type: 'unknown-generic', confidence: 'low', signals: [] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { classifyProject };
|
package/src/generator/index.js
CHANGED
|
@@ -9,6 +9,7 @@ const { parseRoadmap, upsertManagedBlock } = require('../parser');
|
|
|
9
9
|
const { findBestTaskMatch, dedupeTasks } = require('../match');
|
|
10
10
|
const { collectPluginContributions } = require('../config');
|
|
11
11
|
const { renderBody } = require('../renderer');
|
|
12
|
+
const { classifyProject } = require('../classifier');
|
|
12
13
|
|
|
13
14
|
const IMPL_PATTERN_RE = /[/|]TODO|TODO[|/]|[/|]FIXME|FIXME[|/]/;
|
|
14
15
|
const COMMENT_TODO_RE = /(?:\/\/|#|\*\s*).*\b(?:TODO|FIXME)\b/;
|
|
@@ -149,6 +150,8 @@ function scanProject(projectRoot) {
|
|
|
149
150
|
const implementedFiles = files.filter((file) => /\.(js|ts|tsx|py|go|rs|java|kt|cs)$/.test(file));
|
|
150
151
|
const testFiles = files.filter((file) => /(^|\/)(__tests__|tests)\//.test(file) || /\.test\.|\.spec\.|_test\.go$/.test(file));
|
|
151
152
|
|
|
153
|
+
const classifier = classifyProject({ projectRoot, files });
|
|
154
|
+
|
|
152
155
|
return {
|
|
153
156
|
projectRoot,
|
|
154
157
|
files,
|
|
@@ -160,7 +163,10 @@ function scanProject(projectRoot) {
|
|
|
160
163
|
codeTodos,
|
|
161
164
|
workspaces,
|
|
162
165
|
implementedCount: implementedFiles.length,
|
|
163
|
-
testCount: testFiles.length
|
|
166
|
+
testCount: testFiles.length,
|
|
167
|
+
projectType: classifier.type,
|
|
168
|
+
classifierConfidence: classifier.confidence,
|
|
169
|
+
classifierSignals: classifier.signals
|
|
164
170
|
};
|
|
165
171
|
}
|
|
166
172
|
|
|
@@ -175,6 +181,35 @@ function toCandidate(text, phase, priority, source = 'default') {
|
|
|
175
181
|
};
|
|
176
182
|
}
|
|
177
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
|
+
|
|
178
213
|
function buildDefaultCandidates(scan, config) {
|
|
179
214
|
const languageLabel = scan.languages.length > 0 ? scan.languages.join(', ') : 'current stack';
|
|
180
215
|
const candidates = [];
|
|
@@ -223,6 +258,10 @@ function buildDefaultCandidates(scan, config) {
|
|
|
223
258
|
candidates.push(toCandidate(`Resolve backlog note in ${hint.file}`, 'P0', 'P0', 'todo-hint'));
|
|
224
259
|
}
|
|
225
260
|
|
|
261
|
+
if (scan.projectType === 'frontend-web' || scan.projectType === 'landing-site') {
|
|
262
|
+
candidates.push(...buildWebCandidates(scan));
|
|
263
|
+
}
|
|
264
|
+
|
|
226
265
|
return candidates;
|
|
227
266
|
}
|
|
228
267
|
|
|
@@ -547,10 +586,22 @@ function generateRoadmapDocument(options) {
|
|
|
547
586
|
items: section.items || []
|
|
548
587
|
}));
|
|
549
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
|
+
|
|
550
601
|
const baseCandidates = buildDefaultCandidates(scan, config);
|
|
551
602
|
const matcherCandidates = applyTaskMatchers(scan, config);
|
|
552
603
|
const merged = mergeWithExisting([...baseCandidates, ...matcherCandidates, ...pluginTaskCandidates], existingPhaseTasks);
|
|
553
|
-
const model = createModel(scan, merged, config, [...configSections, ...pluginSections], existingCheckedById);
|
|
604
|
+
const model = createModel(scan, merged, config, [profileSection, ...configSections, ...pluginSections], existingCheckedById);
|
|
554
605
|
const profile = config.roadmapProfile || 'compact';
|
|
555
606
|
const managedBody = renderBody(model, profile);
|
|
556
607
|
|
|
@@ -530,6 +530,14 @@ function renderProfessional(model) {
|
|
|
530
530
|
renderSection12SuccessCriteria(model, lines);
|
|
531
531
|
renderSection13CustomPhases(model, lines);
|
|
532
532
|
|
|
533
|
+
for (const section of (model.customSections || [])) {
|
|
534
|
+
lines.push(`## ${section.title}`);
|
|
535
|
+
for (const line of section.items) {
|
|
536
|
+
lines.push(line);
|
|
537
|
+
}
|
|
538
|
+
lines.push('');
|
|
539
|
+
}
|
|
540
|
+
|
|
533
541
|
return ensureTrailingNewline(lines.join('\n')).trimEnd();
|
|
534
542
|
}
|
|
535
543
|
|
package/src/validator/index.js
CHANGED
|
@@ -12,23 +12,40 @@ const CODE_EXTENSIONS = new Set([
|
|
|
12
12
|
'.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb', '.php', '.cs'
|
|
13
13
|
]);
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// "docs" omitted from DOC_HINTS — it is a path prefix in scan tasks, not a doc-authoring keyword.
|
|
16
|
+
const DOC_HINTS = ['readme', 'changelog', 'documentation', 'spec', 'diagram', 'runbook'];
|
|
16
17
|
const CODE_HINTS = ['implement', 'add', 'create', 'build', 'refactor', 'fix', 'module', 'function', 'api', 'endpoint', 'command'];
|
|
17
18
|
const GENERIC_TASK_TOKENS = new Set([
|
|
18
|
-
|
|
19
|
-
'implementation',
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
|
|
23
|
-
'method',
|
|
24
|
-
'
|
|
25
|
-
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
'
|
|
29
|
-
|
|
30
|
-
'
|
|
31
|
-
|
|
19
|
+
// Action verbs too broad to be evidence signals
|
|
20
|
+
'implement', 'implementation', 'create', 'add', 'build', 'refactor', 'fix',
|
|
21
|
+
'detect', 'detection', 'support', 'handle', 'handler', 'update', 'check', 'run',
|
|
22
|
+
'process', 'processing', 'generate', 'generation', 'format', 'report',
|
|
23
|
+
// Structural concepts shared by every codebase
|
|
24
|
+
'module', 'function', 'class', 'method', 'command', 'type', 'value', 'values',
|
|
25
|
+
'output', 'input', 'data',
|
|
26
|
+
// Test vocabulary
|
|
27
|
+
'test', 'tests',
|
|
28
|
+
// Infrastructure names present in nearly every Node/JS project
|
|
29
|
+
'config', 'configuration', 'package', 'json', 'project', 'roadmap',
|
|
30
|
+
// Domain words specific to this tool that appear in non-feature source files
|
|
31
|
+
'confidence', 'profile', 'validation', 'evidence',
|
|
32
|
+
// Package/module field names that appear naturally in any Node.js generator or config file
|
|
33
|
+
'main', 'exports', 'files', 'fields', 'without', 'field',
|
|
34
|
+
// Terminology used in architecture/detection task descriptions that overlaps with source identifiers
|
|
35
|
+
'signals', 'directory', 'directories', 'headers', 'site', 'shebang',
|
|
36
|
+
// Common directory names that appear in import paths — too generic for evidence
|
|
37
|
+
'src', 'lib',
|
|
38
|
+
// Broad task-description verbs and nouns that pollute evidence matching across every codebase
|
|
39
|
+
'task', 'tasks', 'file', 'source', 'code', 'artifact', 'artifacts',
|
|
40
|
+
'generic', 'feature', 'features', 'section', 'sections',
|
|
41
|
+
'user', 'users', 'workflow', 'workflows', 'mode', 'modes', 'replace',
|
|
42
|
+
// Tool-internal vocabulary that appears in non-feature implementation files
|
|
43
|
+
'audit', 'debug', 'signal', 'signals', 'log',
|
|
44
|
+
// English stopwords and function words that appear everywhere — not useful as evidence signals
|
|
45
|
+
'only', 'must', 'what', 'which', 'kind', 'never', 'also', 'each',
|
|
46
|
+
'detected', 'generated', 'existing', 'available',
|
|
47
|
+
// Tool-commentary vocabulary that appears in source comments but describes past/intended behavior
|
|
48
|
+
'phrases', 'conceptual',
|
|
32
49
|
]);
|
|
33
50
|
|
|
34
51
|
const CANONICAL_FILES = {
|
|
@@ -38,9 +55,37 @@ const CANONICAL_FILES = {
|
|
|
38
55
|
license: 'LICENSE'
|
|
39
56
|
};
|
|
40
57
|
|
|
58
|
+
// The roadmap file must never be included in the evidence pool: its task descriptions
|
|
59
|
+
// contain the exact vocabulary of the tasks being validated, which would cause every
|
|
60
|
+
// task to validate itself.
|
|
61
|
+
const SELF_REFERENTIAL_FILES = new Set(['ROADMAP.md']);
|
|
62
|
+
|
|
63
|
+
// Maps task-ID namespace prefix to a predicate on (normalized) file paths.
|
|
64
|
+
// When a task ID has a known namespace, at least one evidence file must satisfy
|
|
65
|
+
// the predicate — otherwise generic token overlap alone cannot pass the task.
|
|
66
|
+
const NAMESPACE_STRUCTURAL_PATTERNS = {
|
|
67
|
+
cls: (p) => /classif(?:ier|y)|archetype/.test(p),
|
|
68
|
+
dsg: (p) => /generator[/\\](?:domain|web|landing|profiles?)|(?:domain|web|landing)[/\\](?:profile|generator)/.test(p),
|
|
69
|
+
evh2: (p) => p.includes('/validator/') || p.includes('\\validator\\'),
|
|
70
|
+
cst: (p) => /smoke|integration[-_]test|e2e/.test(p),
|
|
71
|
+
uxf: (p) => p.includes('/renderer/') || p.includes('\\renderer\\') || /renderer\.[jt]sx?$/.test(p),
|
|
72
|
+
cfgo: (p) => /config[/\\]|schema[/\\]|config\.[jt]s$|schema\.[jt]s$/.test(p),
|
|
73
|
+
doc3: (p) => /(?:^|[/\\])docs[/\\]|readme\.md$/i.test(p),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Test fixture directories contain synthetic code created to drive test scenarios,
|
|
77
|
+
// not real implementations. Including them pollutes the evidence pool with vocabulary
|
|
78
|
+
// that was deliberately seeded for testing purposes (e.g. namespace-vocab fixtures).
|
|
79
|
+
function isFixturePath(relativePath) {
|
|
80
|
+
return /(?:^|[/\\])fixtures[/\\]/.test(relativePath);
|
|
81
|
+
}
|
|
82
|
+
|
|
41
83
|
function readFileIndex(projectRoot, files) {
|
|
42
84
|
const index = [];
|
|
43
85
|
for (const relativePath of files) {
|
|
86
|
+
if (SELF_REFERENTIAL_FILES.has(relativePath)) continue;
|
|
87
|
+
if (isFixturePath(relativePath)) continue;
|
|
88
|
+
|
|
44
89
|
const absolutePath = path.resolve(projectRoot, relativePath);
|
|
45
90
|
const ext = path.extname(relativePath).toLowerCase();
|
|
46
91
|
let content = '';
|
|
@@ -79,10 +124,31 @@ function isLikelyPath(token) {
|
|
|
79
124
|
if (/^\.{1,2}\/|^\//.test(token)) return true;
|
|
80
125
|
if (hasFileExtension(token)) return true;
|
|
81
126
|
if (KNOWN_PATH_ROOTS.some((root) => token.startsWith(root))) return true;
|
|
82
|
-
|
|
127
|
+
// The ">= 2 slashes" rule was intentionally removed: it caused conceptual slash phrases
|
|
128
|
+
// like "code/test/artifact" or "build/test/deploy" to be treated as file paths.
|
|
129
|
+
// Real multi-segment paths are caught by the extension or known-root rules above.
|
|
83
130
|
return false;
|
|
84
131
|
}
|
|
85
132
|
|
|
133
|
+
// Matches standalone filenames without a slash — e.g. "roadmap-skill.config.json",
|
|
134
|
+
// "package.json", "vite.config.ts". These are path references whose component tokens
|
|
135
|
+
// (e.g. "roadmap", "skill") must be excluded from code evidence scoring to prevent
|
|
136
|
+
// circular vocabulary: a task mentioning a filename would otherwise score hits in any
|
|
137
|
+
// source file that happens to reference the same filename for unrelated reasons.
|
|
138
|
+
// Numeric-only tokens like "1.0.0" or "v0.8" are excluded via the leading-digit guard.
|
|
139
|
+
const STANDALONE_FILE_RE = /\b([A-Za-z][A-Za-z0-9_.+-]*\.[A-Za-z0-9]{2,10})\b/g;
|
|
140
|
+
const KNOWN_FILE_EXTENSIONS = new Set([
|
|
141
|
+
'.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs',
|
|
142
|
+
'.java', '.kt', '.swift', '.rb', '.php', '.cs', '.json', '.yaml', '.yml',
|
|
143
|
+
'.toml', '.md', '.txt', '.sh', '.bash', '.env', '.html', '.css', '.scss', '.lock'
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
function hasKnownFileExtension(token) {
|
|
147
|
+
const lastDot = token.lastIndexOf('.');
|
|
148
|
+
if (lastDot < 0) return false;
|
|
149
|
+
return KNOWN_FILE_EXTENSIONS.has(token.slice(lastDot).toLowerCase());
|
|
150
|
+
}
|
|
151
|
+
|
|
86
152
|
function extractExplicitPaths(text) {
|
|
87
153
|
const results = new Set();
|
|
88
154
|
const quoted = String(text).match(/`([^`]+)`/g) || [];
|
|
@@ -102,6 +168,25 @@ function extractExplicitPaths(text) {
|
|
|
102
168
|
return Array.from(results).sort((left, right) => left.localeCompare(right));
|
|
103
169
|
}
|
|
104
170
|
|
|
171
|
+
// Standalone filenames (no slash) mentioned in task prose — e.g. "roadmap-skill.config.json",
|
|
172
|
+
// "package.json". These are filename *references*, NOT path-existence assertions: the author
|
|
173
|
+
// is describing which file contains a feature, not asserting that the file must exist.
|
|
174
|
+
// Used only for pathDerivedToken extraction (to prevent circular vocabulary), never for
|
|
175
|
+
// findFilesByPathHints (which would pass any task whose config file already exists).
|
|
176
|
+
function extractStandaloneFilenames(text) {
|
|
177
|
+
const results = new Set();
|
|
178
|
+
STANDALONE_FILE_RE.lastIndex = 0;
|
|
179
|
+
let m = STANDALONE_FILE_RE.exec(String(text));
|
|
180
|
+
while (m) {
|
|
181
|
+
const token = m[1].replace(/[.,;:!?)]+$/, '');
|
|
182
|
+
if (hasKnownFileExtension(token) && !token.startsWith('.')) {
|
|
183
|
+
results.add(token);
|
|
184
|
+
}
|
|
185
|
+
m = STANDALONE_FILE_RE.exec(String(text));
|
|
186
|
+
}
|
|
187
|
+
return Array.from(results);
|
|
188
|
+
}
|
|
189
|
+
|
|
105
190
|
function extractSymbolHints(text) {
|
|
106
191
|
const symbols = new Set();
|
|
107
192
|
const patterns = [
|
|
@@ -128,7 +213,12 @@ function isCodeTask(taskText) {
|
|
|
128
213
|
|
|
129
214
|
function isDocTask(taskText) {
|
|
130
215
|
const normalized = String(taskText).toLowerCase();
|
|
131
|
-
|
|
216
|
+
// Use word-boundary matching to avoid substring false positives (e.g. "specific" ≠ "spec").
|
|
217
|
+
const hasDocKeyword = DOC_HINTS.some((hint) => new RegExp(`(?<![a-z])${hint}(?![a-z])`).test(normalized));
|
|
218
|
+
if (!hasDocKeyword) return false;
|
|
219
|
+
// Also require a creation/update verb so that policy tasks mentioning doc files
|
|
220
|
+
// ("README must not be used as evidence") don't trigger doc-artifact evidence.
|
|
221
|
+
return /\b(add|create|write|update|init|initialize|introduce|setup|document)\b/.test(normalized);
|
|
132
222
|
}
|
|
133
223
|
|
|
134
224
|
function findFilesByPathHints(pathHints, fileIndex) {
|
|
@@ -166,9 +256,31 @@ function findFilesBySymbols(symbolHints, fileIndex) {
|
|
|
166
256
|
return Array.from(matches).sort((left, right) => left.localeCompare(right));
|
|
167
257
|
}
|
|
168
258
|
|
|
169
|
-
|
|
259
|
+
// Tokens extracted from a referenced file path (e.g. "roadmap-skill" from
|
|
260
|
+
// "roadmap-skill.config.json") must not be reused as code evidence signals.
|
|
261
|
+
// Those tokens appear in any file that mentions the same path — creating circular
|
|
262
|
+
// vocabulary where a task about "X in path/to/file" passes because the source
|
|
263
|
+
// code references the same path for unrelated reasons.
|
|
264
|
+
function extractPathDerivedTokens(pathHints) {
|
|
265
|
+
const tokens = new Set();
|
|
266
|
+
for (const hint of pathHints) {
|
|
267
|
+
// Char-split: "roadmap-skill.config.json" → ["roadmap", "skill", "config", "json"]
|
|
268
|
+
const parts = hint.replace(/[.\-_/\\]/g, ' ').toLowerCase().split(/\s+/).filter(Boolean);
|
|
269
|
+
for (const part of parts) {
|
|
270
|
+
if (part.length >= 3) tokens.add(part);
|
|
271
|
+
}
|
|
272
|
+
// Tokenizer-split: also adds compound tokens the char-split misses, e.g. "roadmap-skill"
|
|
273
|
+
// (the tokenizer preserves hyphens in identifiers; the char-split strips them).
|
|
274
|
+
for (const token of tokenize(hint)) {
|
|
275
|
+
if (token.length >= 3) tokens.add(token);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return tokens;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
|
|
170
282
|
const tokens = tokenize(taskText)
|
|
171
|
-
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token))
|
|
283
|
+
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/') && !pathDerivedTokens.has(token))
|
|
172
284
|
.slice(0, 8);
|
|
173
285
|
if (tokens.length === 0) {
|
|
174
286
|
return [];
|
|
@@ -191,7 +303,9 @@ function findCodeEvidence(taskText, fileIndex) {
|
|
|
191
303
|
}
|
|
192
304
|
}
|
|
193
305
|
|
|
194
|
-
|
|
306
|
+
// Require more matches proportional to how many specific tokens the task has.
|
|
307
|
+
// Tasks with 4+ meaningful tokens need 3 files to match to prevent vocabulary overlap.
|
|
308
|
+
const threshold = tokens.length >= 4 ? 3 : tokens.length >= 2 ? 2 : 1;
|
|
195
309
|
if (score >= threshold) {
|
|
196
310
|
matches.push(file.relativePath);
|
|
197
311
|
}
|
|
@@ -202,18 +316,46 @@ function findCodeEvidence(taskText, fileIndex) {
|
|
|
202
316
|
|
|
203
317
|
function findTestEvidence(taskText, fileIndex) {
|
|
204
318
|
const tokens = tokenize(taskText)
|
|
205
|
-
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token))
|
|
319
|
+
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/'))
|
|
206
320
|
.slice(0, 8);
|
|
321
|
+
|
|
322
|
+
if (tokens.length === 0) return [];
|
|
323
|
+
|
|
324
|
+
// Only tokens of length >= 4 are used for import-reference matching.
|
|
325
|
+
// Very short tokens (e.g. "app", "web") are too generic: they appear as substrings in
|
|
326
|
+
// many import paths that have nothing to do with the feature being validated.
|
|
327
|
+
// The single-short-token fallback below handles the narrow case of one-word module names.
|
|
328
|
+
const importTokens = tokens.filter((token) => token.length >= 4);
|
|
329
|
+
|
|
207
330
|
const matches = [];
|
|
208
331
|
|
|
209
332
|
for (const file of fileIndex) {
|
|
210
|
-
if (!file.isTestFile)
|
|
333
|
+
if (!file.isTestFile) continue;
|
|
334
|
+
|
|
335
|
+
// A test file counts as evidence only when it imports a module whose path contains
|
|
336
|
+
// one of the task's meaningful tokens. Content-keyword matching is intentionally absent:
|
|
337
|
+
// test content (descriptions, literals) can contain future-task vocabulary,
|
|
338
|
+
// producing self-referential false positives.
|
|
339
|
+
//
|
|
340
|
+
// Trailing slashes are NOT stripped: "app/" is a directory reference, not a module name.
|
|
341
|
+
// "../src/app" (a real import) does not contain the string "app/" so it won't match.
|
|
342
|
+
const importRefs = (
|
|
343
|
+
file.content.match(/require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)|from\s+['"`]([^'"`]+)['"`]/g) || []
|
|
344
|
+
).join(' ').toLowerCase();
|
|
345
|
+
|
|
346
|
+
if (importTokens.length > 0 && importTokens.some((token) => importRefs.includes(token))) {
|
|
347
|
+
matches.push(file.relativePath);
|
|
211
348
|
continue;
|
|
212
349
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
350
|
+
|
|
351
|
+
// Narrow fallback: single very-short token (e.g. "app", "cli").
|
|
352
|
+
// Import paths for these are too short to distinguish reliably, so fall back to a
|
|
353
|
+
// content match — but only when there is exactly one such token (no multi-token dilution).
|
|
354
|
+
if (tokens.length === 1 && tokens[0].length < 4) {
|
|
355
|
+
const lowered = file.content.toLowerCase();
|
|
356
|
+
if (lowered.includes(tokens[0])) {
|
|
357
|
+
matches.push(file.relativePath);
|
|
358
|
+
}
|
|
217
359
|
}
|
|
218
360
|
}
|
|
219
361
|
|
|
@@ -225,19 +367,26 @@ function findArtifactEvidence(taskText, fileIndex) {
|
|
|
225
367
|
const files = [];
|
|
226
368
|
const heuristicArtifacts = [];
|
|
227
369
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
370
|
+
// Canonical file detection only applies to short tasks (≤8 words) that are about
|
|
371
|
+
// creating or referencing that specific file. Long sentences that merely MENTION
|
|
372
|
+
// "readme" or "security" in a policy/constraint context are excluded.
|
|
373
|
+
const wordCount = normalized.trim().split(/\s+/).length;
|
|
374
|
+
if (wordCount <= 8) {
|
|
375
|
+
for (const [keyword, filename] of Object.entries(CANONICAL_FILES)) {
|
|
376
|
+
// Use hyphen-aware word boundaries: "security-headers" must not match "security".
|
|
377
|
+
if (new RegExp(`(?<![a-z-])${keyword}(?![a-z-])`).test(normalized)) {
|
|
378
|
+
const hit = fileIndex.find(
|
|
379
|
+
(f) => f.relativePath === filename || f.relativePath.endsWith('/' + filename)
|
|
380
|
+
);
|
|
381
|
+
if (hit) {
|
|
382
|
+
files.push(hit.relativePath);
|
|
383
|
+
heuristicArtifacts.push(hit.relativePath);
|
|
384
|
+
}
|
|
236
385
|
}
|
|
237
386
|
}
|
|
238
387
|
}
|
|
239
388
|
|
|
240
|
-
if (!isDocTask(taskText)
|
|
389
|
+
if (!isDocTask(taskText)) {
|
|
241
390
|
return { files, heuristicArtifacts };
|
|
242
391
|
}
|
|
243
392
|
|
|
@@ -259,6 +408,82 @@ function findArtifactEvidence(taskText, fileIndex) {
|
|
|
259
408
|
return { files: files.slice(0, 20), heuristicArtifacts };
|
|
260
409
|
}
|
|
261
410
|
|
|
411
|
+
function extractTaskNamespace(taskId) {
|
|
412
|
+
if (!taskId) return null;
|
|
413
|
+
const match = String(taskId).match(/^([a-z][a-z0-9]*)-/);
|
|
414
|
+
return match ? match[1] : null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isAcceptanceCriteria(taskId) {
|
|
418
|
+
return /ph\d+[_-]st\d+[_-]exit/.test(String(taskId || ''));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Gate: returns { applicable, passed, structuralFiles, reason }.
|
|
422
|
+
// For namespaces with a defined structural pattern:
|
|
423
|
+
// 1. If no files in fileIndex match the pattern → immediate fail.
|
|
424
|
+
// 2. For acceptance-criteria tasks (phN-stN-exit IDs): path match alone is enough.
|
|
425
|
+
// 3. For implementation tasks: feature tokens from task text must score ≥ ceil(n/2)
|
|
426
|
+
// against namespace-matched files, preventing vocabulary overlap from generic
|
|
427
|
+
// infrastructure code (io.js, generator/index.js) from serving as evidence.
|
|
428
|
+
function checkNamespaceStructuralEvidence(taskId, taskText, fileIndex) {
|
|
429
|
+
const namespace = extractTaskNamespace(taskId);
|
|
430
|
+
if (!namespace || !NAMESPACE_STRUCTURAL_PATTERNS[namespace]) {
|
|
431
|
+
return { applicable: false, passed: true, structuralFiles: [], reason: null };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const predicate = NAMESPACE_STRUCTURAL_PATTERNS[namespace];
|
|
435
|
+
const namespaceFiles = fileIndex.filter((f) => predicate(f.relativePath));
|
|
436
|
+
|
|
437
|
+
if (namespaceFiles.length === 0) {
|
|
438
|
+
return {
|
|
439
|
+
applicable: true,
|
|
440
|
+
passed: false,
|
|
441
|
+
structuralFiles: [],
|
|
442
|
+
reason: `namespace "${namespace}" has no implementation files`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const featureTokens = tokenize(taskText)
|
|
447
|
+
.filter((t) => t.length >= 4 && !GENERIC_TASK_TOKENS.has(t) && !t.endsWith('/'))
|
|
448
|
+
.slice(0, 8);
|
|
449
|
+
|
|
450
|
+
if (featureTokens.length === 0) {
|
|
451
|
+
return {
|
|
452
|
+
applicable: true,
|
|
453
|
+
passed: true,
|
|
454
|
+
structuralFiles: namespaceFiles.map((f) => f.relativePath),
|
|
455
|
+
reason: null,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let bestScore = 0;
|
|
460
|
+
for (const nsFile of namespaceFiles) {
|
|
461
|
+
const lowered = nsFile.content.toLowerCase();
|
|
462
|
+
let score = 0;
|
|
463
|
+
for (const token of featureTokens) {
|
|
464
|
+
if (lowered.includes(token)) score++;
|
|
465
|
+
}
|
|
466
|
+
if (score > bestScore) bestScore = score;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const threshold = Math.max(1, Math.ceil(featureTokens.length / 2));
|
|
470
|
+
if (bestScore >= threshold) {
|
|
471
|
+
return {
|
|
472
|
+
applicable: true,
|
|
473
|
+
passed: true,
|
|
474
|
+
structuralFiles: namespaceFiles.map((f) => f.relativePath),
|
|
475
|
+
reason: null,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
applicable: true,
|
|
481
|
+
passed: false,
|
|
482
|
+
structuralFiles: namespaceFiles.map((f) => f.relativePath),
|
|
483
|
+
reason: `structural token score ${bestScore}/${threshold} in "${namespace}" files — token overlap insufficient`,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
262
487
|
function evaluateRule(rule, task, context) {
|
|
263
488
|
if (!rule) {
|
|
264
489
|
return { passed: true, reasons: [], evidence: {} };
|
|
@@ -342,14 +567,21 @@ function buildValidationContext(projectRoot, config, plugins) {
|
|
|
342
567
|
|
|
343
568
|
function validateTask(task, context, config, plugins) {
|
|
344
569
|
const pathHints = extractExplicitPaths(task.text);
|
|
570
|
+
const standaloneFilenames = extractStandaloneFilenames(task.text);
|
|
345
571
|
const symbolHints = extractSymbolHints(task.text);
|
|
346
572
|
|
|
347
573
|
const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
|
|
348
574
|
const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
|
|
349
|
-
|
|
575
|
+
// Combine path hints AND standalone filenames for token exclusion so that tokens
|
|
576
|
+
// derived from any referenced filename (e.g. "roadmap-skill" from
|
|
577
|
+
// "roadmap-skill.config.json") are excluded from code evidence scoring.
|
|
578
|
+
const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...standaloneFilenames]);
|
|
579
|
+
const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens);
|
|
350
580
|
const filesFromTests = findTestEvidence(task.text, context.fileIndex);
|
|
351
581
|
const { files: filesFromArtifacts, heuristicArtifacts } = findArtifactEvidence(task.text, context.fileIndex);
|
|
352
582
|
|
|
583
|
+
const structuralCheck = checkNamespaceStructuralEvidence(task.id, task.text, context.fileIndex);
|
|
584
|
+
|
|
353
585
|
const evidence = {
|
|
354
586
|
code: filesFromCode.length > 0 || filesFromSymbols.length > 0,
|
|
355
587
|
test: filesFromTests.length > 0,
|
|
@@ -359,7 +591,9 @@ function validateTask(task, context, config, plugins) {
|
|
|
359
591
|
codeFiles: filesFromCode,
|
|
360
592
|
testFiles: filesFromTests,
|
|
361
593
|
artifactFiles: filesFromArtifacts,
|
|
362
|
-
heuristicArtifacts
|
|
594
|
+
heuristicArtifacts,
|
|
595
|
+
structuralEvidence: structuralCheck.applicable ? structuralCheck.passed : null,
|
|
596
|
+
structuralFiles: structuralCheck.structuralFiles,
|
|
363
597
|
};
|
|
364
598
|
|
|
365
599
|
const reasons = [];
|
|
@@ -370,8 +604,16 @@ function validateTask(task, context, config, plugins) {
|
|
|
370
604
|
reasons.push(`missing symbol(s): ${symbolHints.join(', ')}`);
|
|
371
605
|
}
|
|
372
606
|
|
|
607
|
+
// Namespace-structural gate: for known namespaces, token overlap alone is insufficient.
|
|
608
|
+
// The task must have evidence files whose paths match the namespace pattern.
|
|
609
|
+
if (structuralCheck.applicable && !structuralCheck.passed) {
|
|
610
|
+
reasons.push(structuralCheck.reason || `no structural evidence for namespace "${extractTaskNamespace(task.id)}"`);
|
|
611
|
+
}
|
|
612
|
+
|
|
373
613
|
const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
|
|
374
|
-
if (!hasEvidence) {
|
|
614
|
+
if (!hasEvidence && !structuralCheck.applicable) {
|
|
615
|
+
reasons.push('no code, test, or artifact evidence found');
|
|
616
|
+
} else if (!hasEvidence && structuralCheck.applicable && structuralCheck.passed) {
|
|
375
617
|
reasons.push('no code, test, or artifact evidence found');
|
|
376
618
|
}
|
|
377
619
|
|
|
@@ -395,12 +637,17 @@ function validateTask(task, context, config, plugins) {
|
|
|
395
637
|
const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
|
|
396
638
|
const confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
|
|
397
639
|
|
|
640
|
+
// True when the only passing evidence is artifact/doc files and the task is not a doc task.
|
|
641
|
+
// Used by auditValidation to flag implementation tasks that pass solely via documentation.
|
|
642
|
+
const evidenceIsDocOnly = !evidence.code && !evidence.test && evidence.artifact && !isDocTask(task.text);
|
|
643
|
+
|
|
398
644
|
return {
|
|
399
645
|
taskId: task.id,
|
|
400
646
|
passed: uniqueReasons.length === 0,
|
|
401
647
|
confidence,
|
|
402
648
|
reasons: uniqueReasons,
|
|
403
649
|
evidence,
|
|
650
|
+
evidenceIsDocOnly,
|
|
404
651
|
requiresTest,
|
|
405
652
|
hasEvidence,
|
|
406
653
|
attempted
|
|
@@ -418,12 +665,13 @@ function validateTasks(tasks, context, config, plugins) {
|
|
|
418
665
|
function auditValidation(tasks, results) {
|
|
419
666
|
const checkedWithoutEvidence = [];
|
|
420
667
|
const readyButUnchecked = [];
|
|
668
|
+
const checkedWithWeakEvidence = [];
|
|
669
|
+
const documentationOnlyEvidenceForImplementation = [];
|
|
670
|
+
const checkedWithNoStructuralEvidence = [];
|
|
421
671
|
|
|
422
672
|
for (const task of tasks) {
|
|
423
673
|
const result = results[task.id];
|
|
424
|
-
if (!result)
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
674
|
+
if (!result) continue;
|
|
427
675
|
|
|
428
676
|
if (task.checked && !result.passed) {
|
|
429
677
|
checkedWithoutEvidence.push({ task, result });
|
|
@@ -432,11 +680,27 @@ function auditValidation(tasks, results) {
|
|
|
432
680
|
if (!task.checked && result.passed) {
|
|
433
681
|
readyButUnchecked.push({ task, result });
|
|
434
682
|
}
|
|
683
|
+
|
|
684
|
+
if (task.checked && result.passed && result.confidence === 'low') {
|
|
685
|
+
checkedWithWeakEvidence.push({ task, result });
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (task.checked && result.passed && result.evidenceIsDocOnly) {
|
|
689
|
+
documentationOnlyEvidenceForImplementation.push({ task, result });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Checked task that failed specifically because structural evidence is missing.
|
|
693
|
+
if (task.checked && !result.passed && result.evidence.structuralEvidence === false) {
|
|
694
|
+
checkedWithNoStructuralEvidence.push({ task, result });
|
|
695
|
+
}
|
|
435
696
|
}
|
|
436
697
|
|
|
437
698
|
return {
|
|
438
699
|
checkedWithoutEvidence,
|
|
439
|
-
readyButUnchecked
|
|
700
|
+
readyButUnchecked,
|
|
701
|
+
checkedWithWeakEvidence,
|
|
702
|
+
documentationOnlyEvidenceForImplementation,
|
|
703
|
+
checkedWithNoStructuralEvidence,
|
|
440
704
|
};
|
|
441
705
|
}
|
|
442
706
|
|
|
@@ -460,5 +724,7 @@ module.exports = {
|
|
|
460
724
|
validateTask,
|
|
461
725
|
validateTasks,
|
|
462
726
|
CONFIDENCE_RANK,
|
|
463
|
-
applyMinimumConfidence
|
|
727
|
+
applyMinimumConfidence,
|
|
728
|
+
extractTaskNamespace,
|
|
729
|
+
isAcceptanceCriteria,
|
|
464
730
|
};
|