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/validator/index.js
CHANGED
|
@@ -1,730 +1,881 @@
|
|
|
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 CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
10
|
-
|
|
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 CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
10
|
+
|
|
11
11
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'
|
|
23
|
-
|
|
24
|
-
'
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'
|
|
28
|
-
//
|
|
29
|
-
'
|
|
30
|
-
//
|
|
31
|
-
'
|
|
32
|
-
//
|
|
33
|
-
'
|
|
34
|
-
//
|
|
35
|
-
'
|
|
36
|
-
//
|
|
37
|
-
'
|
|
38
|
-
//
|
|
39
|
-
'
|
|
40
|
-
|
|
41
|
-
'
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
//
|
|
45
|
-
'
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
14
|
+
const TRANSLATION_DIR_SEGMENTS = ['locale', 'locales', 'i18n', 'translations'];
|
|
15
|
+
const DEFAULT_EXCLUDED_PATH_PREFIXES = ['.claude/', '.agent/', 'roadmap-skill/'];
|
|
16
|
+
|
|
17
|
+
// "docs" omitted from DOC_HINTS — it is a path prefix in scan tasks, not a doc-authoring keyword.
|
|
18
|
+
const DOC_HINTS = ['readme', 'changelog', 'documentation', 'spec', 'diagram', 'runbook'];
|
|
19
|
+
const CODE_HINTS = ['implement', 'add', 'create', 'build', 'refactor', 'fix', 'module', 'function', 'api', 'endpoint', 'command'];
|
|
20
|
+
const GENERIC_TASK_TOKENS = new Set([
|
|
21
|
+
// Action verbs too broad to be evidence signals
|
|
22
|
+
'implement', 'implementation', 'create', 'add', 'build', 'refactor', 'fix',
|
|
23
|
+
'detect', 'detection', 'support', 'handle', 'handler', 'update', 'check', 'run',
|
|
24
|
+
'process', 'processing', 'generate', 'generation', 'format', 'report',
|
|
25
|
+
// Structural concepts shared by every codebase
|
|
26
|
+
'module', 'function', 'class', 'method', 'command', 'type', 'value', 'values',
|
|
27
|
+
'output', 'input', 'data',
|
|
28
|
+
// Test vocabulary
|
|
29
|
+
'test', 'tests',
|
|
30
|
+
// Infrastructure names present in nearly every Node/JS project
|
|
31
|
+
'config', 'configuration', 'package', 'json', 'project', 'roadmap',
|
|
32
|
+
// Domain words specific to this tool that appear in non-feature source files
|
|
33
|
+
'confidence', 'profile', 'validation', 'evidence',
|
|
34
|
+
// Package/module field names that appear naturally in any Node.js generator or config file
|
|
35
|
+
'main', 'exports', 'files', 'fields', 'without', 'field',
|
|
36
|
+
// Terminology used in architecture/detection task descriptions that overlaps with source identifiers
|
|
37
|
+
'signals', 'directory', 'directories', 'headers', 'site', 'shebang',
|
|
38
|
+
// Common directory names that appear in import paths — too generic for evidence
|
|
39
|
+
'src', 'lib',
|
|
40
|
+
// Broad task-description verbs and nouns that pollute evidence matching across every codebase
|
|
41
|
+
'task', 'tasks', 'file', 'source', 'code', 'artifact', 'artifacts',
|
|
42
|
+
'generic', 'feature', 'features', 'section', 'sections',
|
|
43
|
+
'user', 'users', 'workflow', 'workflows', 'mode', 'modes', 'replace',
|
|
44
|
+
// Tool-internal vocabulary that appears in non-feature implementation files
|
|
45
|
+
'audit', 'debug', 'signal', 'signals', 'log',
|
|
46
|
+
// English stopwords and function words that appear everywhere — not useful as evidence signals
|
|
47
|
+
'only', 'must', 'what', 'which', 'kind', 'never', 'also', 'each',
|
|
48
|
+
'detected', 'generated', 'existing', 'available',
|
|
49
|
+
// Tool-commentary vocabulary that appears in source comments but describes past/intended behavior
|
|
50
|
+
'phrases', 'conceptual',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const CANONICAL_FILES = {
|
|
54
|
+
security: 'SECURITY.md',
|
|
55
|
+
readme: 'README.md',
|
|
56
|
+
changelog: 'CHANGELOG.md',
|
|
57
|
+
license: 'LICENSE'
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// The roadmap file must never be included in the evidence pool: its task descriptions
|
|
61
|
+
// contain the exact vocabulary of the tasks being validated, which would cause every
|
|
62
|
+
// task to validate itself.
|
|
63
|
+
const SELF_REFERENTIAL_FILES = new Set(['ROADMAP.md']);
|
|
64
|
+
|
|
65
|
+
// Maps task-ID namespace prefix to a predicate on (normalized) file paths.
|
|
66
|
+
// When a task ID has a known namespace, at least one evidence file must satisfy
|
|
67
|
+
// the predicate — otherwise generic token overlap alone cannot pass the task.
|
|
68
|
+
const NAMESPACE_STRUCTURAL_PATTERNS = {
|
|
69
|
+
cls: (p) => /classif(?:ier|y)|archetype/.test(p),
|
|
70
|
+
dsg: (p) => /generator[/\\](?:domain|web|landing|profiles?)|(?:domain|web|landing)[/\\](?:profile|generator)/.test(p),
|
|
71
|
+
evh2: (p) => p.includes('/validator/') || p.includes('\\validator\\'),
|
|
72
|
+
cst: (p) => /smoke|integration[-_]test|e2e/.test(p),
|
|
73
|
+
uxf: (p) => p.includes('/renderer/') || p.includes('\\renderer\\') || /renderer\.[jt]sx?$/.test(p),
|
|
74
|
+
cfgo: (p) => /config[/\\]|schema[/\\]|config\.[jt]s$|schema\.[jt]s$/.test(p),
|
|
75
|
+
doc3: (p) => /(?:^|[/\\])docs[/\\]|readme\.md$/i.test(p),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Test fixture directories contain synthetic code created to drive test scenarios,
|
|
79
|
+
// not real implementations. Including them pollutes the evidence pool with vocabulary
|
|
80
|
+
// that was deliberately seeded for testing purposes (e.g. namespace-vocab fixtures).
|
|
79
81
|
function isFixturePath(relativePath) {
|
|
80
82
|
return /(?:^|[/\\])fixtures[/\\]/.test(relativePath);
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
for (const relativePath of files) {
|
|
86
|
-
if (SELF_REFERENTIAL_FILES.has(relativePath)) continue;
|
|
87
|
-
if (isFixturePath(relativePath)) continue;
|
|
88
|
-
|
|
89
|
-
const absolutePath = path.resolve(projectRoot, relativePath);
|
|
90
|
-
const ext = path.extname(relativePath).toLowerCase();
|
|
91
|
-
let content = '';
|
|
92
|
-
try {
|
|
93
|
-
const buffer = fs.readFileSync(absolutePath);
|
|
94
|
-
if (buffer.length > 512 * 1024) {
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
content = buffer.toString('utf8');
|
|
98
|
-
} catch {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
index.push({
|
|
103
|
-
relativePath,
|
|
104
|
-
absolutePath,
|
|
105
|
-
ext,
|
|
106
|
-
content,
|
|
107
|
-
isTestFile: /(^|\/)(__tests__|tests)\//.test(relativePath) || /\.test\.|\.spec\.|_test\.go$/.test(relativePath)
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
return index;
|
|
85
|
+
function normalizePathForMatch(rawPath) {
|
|
86
|
+
return String(rawPath || '').replace(/\\/g, '/').toLowerCase();
|
|
111
87
|
}
|
|
112
88
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
function hasFileExtension(token) {
|
|
119
|
-
const lastSegment = token.replace(/\\/g, '/').split('/').pop() || '';
|
|
120
|
-
return /\.[A-Za-z0-9]{1,10}$/.test(lastSegment);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function isLikelyPath(token) {
|
|
124
|
-
if (/^\.{1,2}\/|^\//.test(token)) return true;
|
|
125
|
-
if (hasFileExtension(token)) return true;
|
|
126
|
-
if (KNOWN_PATH_ROOTS.some((root) => token.startsWith(root))) return true;
|
|
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.
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
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
|
-
|
|
152
|
-
function extractExplicitPaths(text) {
|
|
153
|
-
const results = new Set();
|
|
154
|
-
const quoted = String(text).match(/`([^`]+)`/g) || [];
|
|
155
|
-
for (const token of quoted) {
|
|
156
|
-
const clean = token.slice(1, -1);
|
|
157
|
-
if (clean.includes('/') || clean.includes('\\') || clean.includes('.')) {
|
|
158
|
-
results.add(clean);
|
|
159
|
-
}
|
|
89
|
+
function shouldExcludeByDefaultPath(relativePath, config) {
|
|
90
|
+
const normalized = normalizePathForMatch(relativePath);
|
|
91
|
+
if (DEFAULT_EXCLUDED_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
|
92
|
+
return true;
|
|
160
93
|
}
|
|
161
94
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
95
|
+
const configuredSkillsDir = config && typeof config.skillsDir === 'string'
|
|
96
|
+
? normalizePathForMatch(config.skillsDir).replace(/^\.?\//, '')
|
|
97
|
+
: '';
|
|
98
|
+
if (configuredSkillsDir && (normalized === configuredSkillsDir || normalized.startsWith(configuredSkillsDir + '/'))) {
|
|
99
|
+
return true;
|
|
166
100
|
}
|
|
167
|
-
|
|
168
|
-
return Array.from(results).sort((left, right) => left.localeCompare(right));
|
|
101
|
+
return false;
|
|
169
102
|
}
|
|
170
103
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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);
|
|
104
|
+
function isTranslationPath(relativePath) {
|
|
105
|
+
const normalized = normalizePathForMatch(relativePath);
|
|
106
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
107
|
+
return segments.some((segment) => TRANSLATION_DIR_SEGMENTS.includes(segment));
|
|
188
108
|
}
|
|
189
109
|
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
];
|
|
197
|
-
|
|
198
|
-
for (const pattern of patterns) {
|
|
199
|
-
let match = pattern.exec(text);
|
|
200
|
-
while (match) {
|
|
201
|
-
symbols.add(match[1]);
|
|
202
|
-
match = pattern.exec(text);
|
|
203
|
-
}
|
|
110
|
+
function looksLikeTranslationJson(content) {
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(content);
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
204
116
|
}
|
|
205
117
|
|
|
206
|
-
|
|
207
|
-
|
|
118
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
208
121
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
122
|
+
const values = Object.values(parsed);
|
|
123
|
+
if (values.length === 0) return false;
|
|
124
|
+
const stringValues = values.filter((value) => typeof value === 'string').length;
|
|
125
|
+
return stringValues / values.length >= 0.8;
|
|
212
126
|
}
|
|
213
127
|
|
|
214
|
-
function
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
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);
|
|
128
|
+
function isMostlyUiStrings(content) {
|
|
129
|
+
const lines = String(content).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
130
|
+
if (lines.length < 5) return false;
|
|
131
|
+
const stringLikeLines = lines.filter((line) => /^['"`][^'"`]{1,200}['"`],?$/.test(line) || /^[A-Za-z0-9_.-]+\s*:\s*['"`][^'"`]{1,200}['"`],?$/.test(line)).length;
|
|
132
|
+
return stringLikeLines / lines.length > 0.8;
|
|
222
133
|
}
|
|
134
|
+
|
|
135
|
+
function readFileIndex(projectRoot, files, config) {
|
|
136
|
+
const index = [];
|
|
137
|
+
for (const relativePath of files) {
|
|
138
|
+
if (SELF_REFERENTIAL_FILES.has(relativePath)) continue;
|
|
139
|
+
if (isFixturePath(relativePath)) continue;
|
|
140
|
+
if (shouldExcludeByDefaultPath(relativePath, config)) continue;
|
|
223
141
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
142
|
+
const absolutePath = path.resolve(projectRoot, relativePath);
|
|
143
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
144
|
+
let content = '';
|
|
145
|
+
try {
|
|
146
|
+
const buffer = fs.readFileSync(absolutePath);
|
|
147
|
+
if (buffer.length > 512 * 1024) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
content = buffer.toString('utf8');
|
|
151
|
+
} catch {
|
|
231
152
|
continue;
|
|
232
153
|
}
|
|
233
154
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return Array.from(new Set(matches)).sort((left, right) => left.localeCompare(right));
|
|
241
|
-
}
|
|
155
|
+
if (isTranslationPath(relativePath)) continue;
|
|
156
|
+
if (ext === '.json' && looksLikeTranslationJson(content)) continue;
|
|
157
|
+
if (isMostlyUiStrings(content)) continue;
|
|
242
158
|
|
|
159
|
+
index.push({
|
|
160
|
+
relativePath,
|
|
161
|
+
absolutePath,
|
|
162
|
+
ext,
|
|
163
|
+
content,
|
|
164
|
+
isTestFile: /(^|\/)(__tests__|tests)\//.test(relativePath) || /\.test\.|\.spec\.|_test\.go$/.test(relativePath)
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return index;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const KNOWN_PATH_ROOTS = [
|
|
171
|
+
'src/', 'lib/', 'bin/', 'test/', 'tests/', 'docs/', 'scripts/',
|
|
172
|
+
'packages/', 'apps/', 'tools/', '.github/', 'roadmap-skill/'
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
function hasFileExtension(token) {
|
|
176
|
+
const lastSegment = token.replace(/\\/g, '/').split('/').pop() || '';
|
|
177
|
+
return /\.[A-Za-z0-9]{1,10}$/.test(lastSegment);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isLikelyPath(token) {
|
|
181
|
+
if (/^\.{1,2}\/|^\//.test(token)) return true;
|
|
182
|
+
if (hasFileExtension(token)) return true;
|
|
183
|
+
if (KNOWN_PATH_ROOTS.some((root) => token.startsWith(root))) return true;
|
|
184
|
+
// The ">= 2 slashes" rule was intentionally removed: it caused conceptual slash phrases
|
|
185
|
+
// like "code/test/artifact" or "build/test/deploy" to be treated as file paths.
|
|
186
|
+
// Real multi-segment paths are caught by the extension or known-root rules above.
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Matches standalone filenames without a slash — e.g. "roadmap-skill.config.json",
|
|
191
|
+
// "package.json", "vite.config.ts". These are path references whose component tokens
|
|
192
|
+
// (e.g. "roadmap", "skill") must be excluded from code evidence scoring to prevent
|
|
193
|
+
// circular vocabulary: a task mentioning a filename would otherwise score hits in any
|
|
194
|
+
// source file that happens to reference the same filename for unrelated reasons.
|
|
195
|
+
// Numeric-only tokens like "1.0.0" or "v0.8" are excluded via the leading-digit guard.
|
|
196
|
+
const STANDALONE_FILE_RE = /\b([A-Za-z][A-Za-z0-9_.+-]*\.[A-Za-z0-9]{2,10})\b/g;
|
|
197
|
+
const KNOWN_FILE_EXTENSIONS = new Set([
|
|
198
|
+
'.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs',
|
|
199
|
+
'.java', '.kt', '.swift', '.rb', '.php', '.cs', '.json', '.yaml', '.yml',
|
|
200
|
+
'.toml', '.md', '.txt', '.sh', '.bash', '.env', '.html', '.css', '.scss', '.lock'
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
function hasKnownFileExtension(token) {
|
|
204
|
+
const lastDot = token.lastIndexOf('.');
|
|
205
|
+
if (lastDot < 0) return false;
|
|
206
|
+
return KNOWN_FILE_EXTENSIONS.has(token.slice(lastDot).toLowerCase());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function extractExplicitPaths(text) {
|
|
210
|
+
const results = new Set();
|
|
211
|
+
const quoted = String(text).match(/`([^`]+)`/g) || [];
|
|
212
|
+
for (const token of quoted) {
|
|
213
|
+
const clean = token.slice(1, -1);
|
|
214
|
+
if (clean.includes('/') || clean.includes('\\') || clean.includes('.')) {
|
|
215
|
+
results.add(clean);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const pathTokens = String(text).match(/([A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+)/g) || [];
|
|
220
|
+
for (const raw of pathTokens) {
|
|
221
|
+
const token = raw.replace(/[.,;:!?)]+$/, '');
|
|
222
|
+
if (isLikelyPath(token)) results.add(token);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return Array.from(results).sort((left, right) => left.localeCompare(right));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Standalone filenames (no slash) mentioned in task prose — e.g. "roadmap-skill.config.json",
|
|
229
|
+
// "package.json". These are filename *references*, NOT path-existence assertions: the author
|
|
230
|
+
// is describing which file contains a feature, not asserting that the file must exist.
|
|
231
|
+
// Used only for pathDerivedToken extraction (to prevent circular vocabulary), never for
|
|
232
|
+
// findFilesByPathHints (which would pass any task whose config file already exists).
|
|
233
|
+
function extractStandaloneFilenames(text) {
|
|
234
|
+
const results = new Set();
|
|
235
|
+
STANDALONE_FILE_RE.lastIndex = 0;
|
|
236
|
+
let m = STANDALONE_FILE_RE.exec(String(text));
|
|
237
|
+
while (m) {
|
|
238
|
+
const token = m[1].replace(/[.,;:!?)]+$/, '');
|
|
239
|
+
if (hasKnownFileExtension(token) && !token.startsWith('.')) {
|
|
240
|
+
results.add(token);
|
|
241
|
+
}
|
|
242
|
+
m = STANDALONE_FILE_RE.exec(String(text));
|
|
243
|
+
}
|
|
244
|
+
return Array.from(results);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function extractSymbolHints(text) {
|
|
248
|
+
const symbols = new Set();
|
|
249
|
+
const patterns = [
|
|
250
|
+
/(?:function|class|method|command)\s+([A-Za-z_][A-Za-z0-9_]*)/gi,
|
|
251
|
+
/(?:function|module|class|command|method)\s+`([A-Za-z_][A-Za-z0-9_-]*)`/gi,
|
|
252
|
+
/`([A-Za-z_][A-Za-z0-9_-]*)`\s+(?:function|module|class|command|method)/gi
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
for (const pattern of patterns) {
|
|
256
|
+
let match = pattern.exec(text);
|
|
257
|
+
while (match) {
|
|
258
|
+
symbols.add(match[1]);
|
|
259
|
+
match = pattern.exec(text);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return Array.from(symbols).sort((left, right) => left.localeCompare(right));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isCodeTask(taskText) {
|
|
267
|
+
const normalized = String(taskText).toLowerCase();
|
|
268
|
+
return CODE_HINTS.some((hint) => normalized.includes(hint));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isDocTask(taskText) {
|
|
272
|
+
const normalized = String(taskText).toLowerCase();
|
|
273
|
+
// Use word-boundary matching to avoid substring false positives (e.g. "specific" ≠ "spec").
|
|
274
|
+
const hasDocKeyword = DOC_HINTS.some((hint) => new RegExp(`(?<![a-z])${hint}(?![a-z])`).test(normalized));
|
|
275
|
+
if (!hasDocKeyword) return false;
|
|
276
|
+
// Also require a creation/update verb so that policy tasks mentioning doc files
|
|
277
|
+
// ("README must not be used as evidence") don't trigger doc-artifact evidence.
|
|
278
|
+
return /\b(add|create|write|update|init|initialize|introduce|setup|document)\b/.test(normalized);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function findFilesByPathHints(pathHints, fileIndex) {
|
|
282
|
+
const matches = [];
|
|
283
|
+
for (const hint of pathHints) {
|
|
284
|
+
const normalizedHint = hint.replace(/\\/g, '/');
|
|
285
|
+
const direct = fileIndex.find((file) => file.relativePath === normalizedHint);
|
|
286
|
+
if (direct) {
|
|
287
|
+
matches.push(direct.relativePath);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const file of fileIndex) {
|
|
292
|
+
if (file.relativePath.endsWith(normalizedHint)) {
|
|
293
|
+
matches.push(file.relativePath);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return Array.from(new Set(matches)).sort((left, right) => left.localeCompare(right));
|
|
298
|
+
}
|
|
299
|
+
|
|
243
300
|
function findFilesBySymbols(symbolHints, fileIndex) {
|
|
244
|
-
const matches = new Set();
|
|
245
|
-
for (const symbol of symbolHints) {
|
|
246
|
-
const regex = new RegExp(`\\b${escapeRegExp(symbol)}\\b`, 'i');
|
|
247
|
-
for (const file of fileIndex) {
|
|
248
|
-
if (!CODE_EXTENSIONS.has(file.ext)) {
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (regex.test(file.content)) {
|
|
252
|
-
matches.add(file.relativePath);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return Array.from(matches).sort((left, right) => left.localeCompare(right));
|
|
301
|
+
const matches = new Set();
|
|
302
|
+
for (const symbol of symbolHints) {
|
|
303
|
+
const regex = new RegExp(`\\b${escapeRegExp(symbol)}\\b`, 'i');
|
|
304
|
+
for (const file of fileIndex) {
|
|
305
|
+
if (!CODE_EXTENSIONS.has(file.ext)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (regex.test(file.content)) {
|
|
309
|
+
matches.add(file.relativePath);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return Array.from(matches).sort((left, right) => left.localeCompare(right));
|
|
257
314
|
}
|
|
258
315
|
|
|
259
|
-
|
|
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()) {
|
|
316
|
+
function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new Set()) {
|
|
282
317
|
const tokens = tokenize(taskText)
|
|
283
|
-
.filter((token) => token.length >=
|
|
284
|
-
.slice(0,
|
|
285
|
-
if (tokens.length === 0)
|
|
286
|
-
return [];
|
|
287
|
-
}
|
|
318
|
+
.filter((token) => token.length >= 6 && !GENERIC_TASK_TOKENS.has(token) && !pathDerivedTokens.has(token))
|
|
319
|
+
.slice(0, 10);
|
|
320
|
+
if (tokens.length === 0) return [];
|
|
288
321
|
|
|
289
|
-
const matches =
|
|
322
|
+
const matches = new Set();
|
|
290
323
|
for (const file of fileIndex) {
|
|
291
|
-
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
let score = 0;
|
|
296
|
-
const lowered = file.content.toLowerCase();
|
|
324
|
+
const pathSegments = normalizePathForMatch(file.relativePath).split('/').filter(Boolean);
|
|
297
325
|
for (const token of tokens) {
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (lowered.includes(token)) {
|
|
302
|
-
score += 1;
|
|
326
|
+
if (pathSegments.some((segment) => segment === token || segment.includes(token))) {
|
|
327
|
+
matches.add(file.relativePath);
|
|
328
|
+
break;
|
|
303
329
|
}
|
|
304
330
|
}
|
|
305
|
-
|
|
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;
|
|
309
|
-
if (score >= threshold) {
|
|
310
|
-
matches.push(file.relativePath);
|
|
311
|
-
}
|
|
331
|
+
if (matches.size >= 20) break;
|
|
312
332
|
}
|
|
313
|
-
|
|
314
|
-
return matches.slice(0, 20);
|
|
333
|
+
return Array.from(matches).sort((left, right) => left.localeCompare(right));
|
|
315
334
|
}
|
|
316
335
|
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
.slice(0, 8);
|
|
321
|
-
|
|
322
|
-
if (tokens.length === 0) return [];
|
|
336
|
+
function mergeRuleEvidence(baseEvidence, ruleEvidence) {
|
|
337
|
+
if (!ruleEvidence || typeof ruleEvidence !== 'object') return baseEvidence;
|
|
338
|
+
const merged = { ...baseEvidence };
|
|
323
339
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const importTokens = tokens.filter((token) => token.length >= 4);
|
|
329
|
-
|
|
330
|
-
const matches = [];
|
|
331
|
-
|
|
332
|
-
for (const file of fileIndex) {
|
|
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);
|
|
340
|
+
for (const [key, value] of Object.entries(ruleEvidence)) {
|
|
341
|
+
if (Array.isArray(value)) {
|
|
342
|
+
const existing = Array.isArray(merged[key]) ? merged[key] : [];
|
|
343
|
+
merged[key] = Array.from(new Set([...existing, ...value]));
|
|
348
344
|
continue;
|
|
349
345
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return matches.slice(0, 20);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function findArtifactEvidence(taskText, fileIndex) {
|
|
366
|
-
const normalized = String(taskText).toLowerCase();
|
|
367
|
-
const files = [];
|
|
368
|
-
const heuristicArtifacts = [];
|
|
369
|
-
|
|
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
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
if (!isDocTask(taskText)) {
|
|
390
|
-
return { files, heuristicArtifacts };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const artifactPatterns = [
|
|
394
|
-
/^README\.md$/i,
|
|
395
|
-
/^CHANGELOG\.md$/i,
|
|
396
|
-
/^docs\//i,
|
|
397
|
-
/^artifacts\//i,
|
|
398
|
-
/^dist\//i,
|
|
399
|
-
/^build\//i
|
|
400
|
-
];
|
|
401
|
-
|
|
402
|
-
for (const file of fileIndex) {
|
|
403
|
-
if (artifactPatterns.some((pattern) => pattern.test(file.relativePath)) && !files.includes(file.relativePath)) {
|
|
404
|
-
files.push(file.relativePath);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
return { files: files.slice(0, 20), heuristicArtifacts };
|
|
409
|
-
}
|
|
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++;
|
|
346
|
+
if (typeof value === 'boolean') {
|
|
347
|
+
merged[key] = Boolean(merged[key]) || value;
|
|
348
|
+
continue;
|
|
465
349
|
}
|
|
466
|
-
|
|
350
|
+
merged[key] = value;
|
|
467
351
|
}
|
|
468
352
|
|
|
469
|
-
|
|
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
|
-
};
|
|
353
|
+
return merged;
|
|
485
354
|
}
|
|
486
|
-
|
|
355
|
+
|
|
356
|
+
// Tokens extracted from a referenced file path (e.g. "roadmap-skill" from
|
|
357
|
+
// "roadmap-skill.config.json") must not be reused as code evidence signals.
|
|
358
|
+
// Those tokens appear in any file that mentions the same path — creating circular
|
|
359
|
+
// vocabulary where a task about "X in path/to/file" passes because the source
|
|
360
|
+
// code references the same path for unrelated reasons.
|
|
361
|
+
function extractPathDerivedTokens(pathHints) {
|
|
362
|
+
const tokens = new Set();
|
|
363
|
+
for (const hint of pathHints) {
|
|
364
|
+
// Char-split: "roadmap-skill.config.json" → ["roadmap", "skill", "config", "json"]
|
|
365
|
+
const parts = hint.replace(/[.\-_/\\]/g, ' ').toLowerCase().split(/\s+/).filter(Boolean);
|
|
366
|
+
for (const part of parts) {
|
|
367
|
+
if (part.length >= 3) tokens.add(part);
|
|
368
|
+
}
|
|
369
|
+
// Tokenizer-split: also adds compound tokens the char-split misses, e.g. "roadmap-skill"
|
|
370
|
+
// (the tokenizer preserves hyphens in identifiers; the char-split strips them).
|
|
371
|
+
for (const token of tokenize(hint)) {
|
|
372
|
+
if (token.length >= 3) tokens.add(token);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return tokens;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
|
|
379
|
+
const tokens = tokenize(taskText)
|
|
380
|
+
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/') && !pathDerivedTokens.has(token))
|
|
381
|
+
.slice(0, 8);
|
|
382
|
+
if (tokens.length === 0) {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const matches = [];
|
|
387
|
+
for (const file of fileIndex) {
|
|
388
|
+
if (!CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let score = 0;
|
|
393
|
+
const lowered = file.content.toLowerCase();
|
|
394
|
+
for (const token of tokens) {
|
|
395
|
+
if (token.length < 3) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (lowered.includes(token)) {
|
|
399
|
+
score += 1;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Require more matches proportional to how many specific tokens the task has.
|
|
404
|
+
// Tasks with 4+ meaningful tokens need 3 files to match to prevent vocabulary overlap.
|
|
405
|
+
const threshold = tokens.length >= 4 ? 3 : tokens.length >= 2 ? 2 : 1;
|
|
406
|
+
if (score >= threshold) {
|
|
407
|
+
matches.push(file.relativePath);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return matches.slice(0, 20);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function findTestEvidence(taskText, fileIndex) {
|
|
415
|
+
const tokens = tokenize(taskText)
|
|
416
|
+
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/'))
|
|
417
|
+
.slice(0, 8);
|
|
418
|
+
|
|
419
|
+
if (tokens.length === 0) return [];
|
|
420
|
+
|
|
421
|
+
// Only tokens of length >= 4 are used for import-reference matching.
|
|
422
|
+
// Very short tokens (e.g. "app", "web") are too generic: they appear as substrings in
|
|
423
|
+
// many import paths that have nothing to do with the feature being validated.
|
|
424
|
+
// The single-short-token fallback below handles the narrow case of one-word module names.
|
|
425
|
+
const importTokens = tokens.filter((token) => token.length >= 4);
|
|
426
|
+
|
|
427
|
+
const matches = [];
|
|
428
|
+
|
|
429
|
+
for (const file of fileIndex) {
|
|
430
|
+
if (!file.isTestFile) continue;
|
|
431
|
+
|
|
432
|
+
// A test file counts as evidence only when it imports a module whose path contains
|
|
433
|
+
// one of the task's meaningful tokens. Content-keyword matching is intentionally absent:
|
|
434
|
+
// test content (descriptions, literals) can contain future-task vocabulary,
|
|
435
|
+
// producing self-referential false positives.
|
|
436
|
+
//
|
|
437
|
+
// Trailing slashes are NOT stripped: "app/" is a directory reference, not a module name.
|
|
438
|
+
// "../src/app" (a real import) does not contain the string "app/" so it won't match.
|
|
439
|
+
const importRefs = (
|
|
440
|
+
file.content.match(/require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)|from\s+['"`]([^'"`]+)['"`]/g) || []
|
|
441
|
+
).join(' ').toLowerCase();
|
|
442
|
+
|
|
443
|
+
if (importTokens.length > 0 && importTokens.some((token) => importRefs.includes(token))) {
|
|
444
|
+
matches.push(file.relativePath);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Narrow fallback: single very-short token (e.g. "app", "cli").
|
|
449
|
+
// Import paths for these are too short to distinguish reliably, so fall back to a
|
|
450
|
+
// content match — but only when there is exactly one such token (no multi-token dilution).
|
|
451
|
+
if (tokens.length === 1 && tokens[0].length < 4) {
|
|
452
|
+
const lowered = file.content.toLowerCase();
|
|
453
|
+
if (lowered.includes(tokens[0])) {
|
|
454
|
+
matches.push(file.relativePath);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return matches.slice(0, 20);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function findArtifactEvidence(taskText, fileIndex) {
|
|
463
|
+
const normalized = String(taskText).toLowerCase();
|
|
464
|
+
const files = [];
|
|
465
|
+
const heuristicArtifacts = [];
|
|
466
|
+
|
|
467
|
+
// Canonical file detection only applies to short tasks (≤8 words) that are about
|
|
468
|
+
// creating or referencing that specific file. Long sentences that merely MENTION
|
|
469
|
+
// "readme" or "security" in a policy/constraint context are excluded.
|
|
470
|
+
const wordCount = normalized.trim().split(/\s+/).length;
|
|
471
|
+
if (wordCount <= 8) {
|
|
472
|
+
for (const [keyword, filename] of Object.entries(CANONICAL_FILES)) {
|
|
473
|
+
// Use hyphen-aware word boundaries: "security-headers" must not match "security".
|
|
474
|
+
if (new RegExp(`(?<![a-z-])${keyword}(?![a-z-])`).test(normalized)) {
|
|
475
|
+
const hit = fileIndex.find(
|
|
476
|
+
(f) => f.relativePath === filename || f.relativePath.endsWith('/' + filename)
|
|
477
|
+
);
|
|
478
|
+
if (hit) {
|
|
479
|
+
files.push(hit.relativePath);
|
|
480
|
+
heuristicArtifacts.push(hit.relativePath);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (!isDocTask(taskText)) {
|
|
487
|
+
return { files, heuristicArtifacts };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const artifactPatterns = [
|
|
491
|
+
/^README\.md$/i,
|
|
492
|
+
/^CHANGELOG\.md$/i,
|
|
493
|
+
/^docs\//i,
|
|
494
|
+
/^artifacts\//i,
|
|
495
|
+
/^dist\//i,
|
|
496
|
+
/^build\//i
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
for (const file of fileIndex) {
|
|
500
|
+
if (artifactPatterns.some((pattern) => pattern.test(file.relativePath)) && !files.includes(file.relativePath)) {
|
|
501
|
+
files.push(file.relativePath);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return { files: files.slice(0, 20), heuristicArtifacts };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function extractTaskNamespace(taskId) {
|
|
509
|
+
if (!taskId) return null;
|
|
510
|
+
const match = String(taskId).match(/^([a-z][a-z0-9]*)-/);
|
|
511
|
+
return match ? match[1] : null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isAcceptanceCriteria(taskId) {
|
|
515
|
+
return /ph\d+[_-]st\d+[_-]exit/.test(String(taskId || ''));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Gate: returns { applicable, passed, structuralFiles, reason }.
|
|
519
|
+
// For namespaces with a defined structural pattern:
|
|
520
|
+
// 1. If no files in fileIndex match the pattern → immediate fail.
|
|
521
|
+
// 2. For acceptance-criteria tasks (phN-stN-exit IDs): path match alone is enough.
|
|
522
|
+
// 3. For implementation tasks: feature tokens from task text must score ≥ ceil(n/2)
|
|
523
|
+
// against namespace-matched files, preventing vocabulary overlap from generic
|
|
524
|
+
// infrastructure code (io.js, generator/index.js) from serving as evidence.
|
|
525
|
+
function checkNamespaceStructuralEvidence(taskId, taskText, fileIndex) {
|
|
526
|
+
const namespace = extractTaskNamespace(taskId);
|
|
527
|
+
if (!namespace || !NAMESPACE_STRUCTURAL_PATTERNS[namespace]) {
|
|
528
|
+
return { applicable: false, passed: true, structuralFiles: [], reason: null };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const predicate = NAMESPACE_STRUCTURAL_PATTERNS[namespace];
|
|
532
|
+
const namespaceFiles = fileIndex.filter((f) => predicate(f.relativePath));
|
|
533
|
+
|
|
534
|
+
if (namespaceFiles.length === 0) {
|
|
535
|
+
return {
|
|
536
|
+
applicable: true,
|
|
537
|
+
passed: false,
|
|
538
|
+
structuralFiles: [],
|
|
539
|
+
reason: `namespace "${namespace}" has no implementation files`,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const featureTokens = tokenize(taskText)
|
|
544
|
+
.filter((t) => t.length >= 4 && !GENERIC_TASK_TOKENS.has(t) && !t.endsWith('/'))
|
|
545
|
+
.slice(0, 8);
|
|
546
|
+
|
|
547
|
+
if (featureTokens.length === 0) {
|
|
548
|
+
return {
|
|
549
|
+
applicable: true,
|
|
550
|
+
passed: true,
|
|
551
|
+
structuralFiles: namespaceFiles.map((f) => f.relativePath),
|
|
552
|
+
reason: null,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
let bestScore = 0;
|
|
557
|
+
for (const nsFile of namespaceFiles) {
|
|
558
|
+
const lowered = nsFile.content.toLowerCase();
|
|
559
|
+
let score = 0;
|
|
560
|
+
for (const token of featureTokens) {
|
|
561
|
+
if (lowered.includes(token)) score++;
|
|
562
|
+
}
|
|
563
|
+
if (score > bestScore) bestScore = score;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const threshold = Math.max(1, Math.ceil(featureTokens.length / 2));
|
|
567
|
+
if (bestScore >= threshold) {
|
|
568
|
+
return {
|
|
569
|
+
applicable: true,
|
|
570
|
+
passed: true,
|
|
571
|
+
structuralFiles: namespaceFiles.map((f) => f.relativePath),
|
|
572
|
+
reason: null,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
applicable: true,
|
|
578
|
+
passed: false,
|
|
579
|
+
structuralFiles: namespaceFiles.map((f) => f.relativePath),
|
|
580
|
+
reason: `structural token score ${bestScore}/${threshold} in "${namespace}" files — token overlap insufficient`,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
487
584
|
function evaluateRule(rule, task, context) {
|
|
488
585
|
if (!rule) {
|
|
489
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
586
|
+
return { passed: true, reasons: [], evidence: {}, overrideResult: false };
|
|
490
587
|
}
|
|
491
|
-
|
|
492
|
-
if (rule.when) {
|
|
493
|
-
const regexp = new RegExp(rule.when, 'i');
|
|
588
|
+
|
|
589
|
+
if (rule.when) {
|
|
590
|
+
const regexp = new RegExp(rule.when, 'i');
|
|
494
591
|
if (!regexp.test(task.text)) {
|
|
495
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
592
|
+
return { passed: true, reasons: [], evidence: {}, overrideResult: false };
|
|
496
593
|
}
|
|
497
594
|
}
|
|
498
|
-
|
|
499
|
-
if (typeof rule.check === 'function') {
|
|
500
|
-
const custom = rule.check(task, context);
|
|
595
|
+
|
|
596
|
+
if (typeof rule.check === 'function') {
|
|
597
|
+
const custom = rule.check(task, context);
|
|
501
598
|
if (!custom) {
|
|
502
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
599
|
+
return { passed: true, reasons: [], evidence: {}, overrideResult: false };
|
|
503
600
|
}
|
|
504
601
|
return {
|
|
505
602
|
passed: custom.passed !== false,
|
|
506
603
|
reasons: Array.isArray(custom.reasons) ? custom.reasons : [],
|
|
507
|
-
evidence: custom.evidence || {}
|
|
604
|
+
evidence: custom.evidence || {},
|
|
605
|
+
overrideResult: rule.overrideResult === true || custom.overrideResult === true
|
|
508
606
|
};
|
|
509
607
|
}
|
|
510
|
-
|
|
511
|
-
const reasons = [];
|
|
512
|
-
const evidence = {};
|
|
513
|
-
|
|
514
|
-
if (rule.type === 'file-exists' && rule.path) {
|
|
515
|
-
const hit = context.fileIndex.find((file) => file.relativePath === rule.path || file.relativePath.endsWith(rule.path));
|
|
516
|
-
if (!hit) {
|
|
517
|
-
reasons.push(rule.message || `missing file: ${rule.path}`);
|
|
518
|
-
} else {
|
|
519
|
-
evidence.file = hit.relativePath;
|
|
520
|
-
}
|
|
608
|
+
|
|
609
|
+
const reasons = [];
|
|
610
|
+
const evidence = {};
|
|
611
|
+
|
|
612
|
+
if (rule.type === 'file-exists' && rule.path) {
|
|
613
|
+
const hit = context.fileIndex.find((file) => file.relativePath === rule.path || file.relativePath.endsWith(rule.path));
|
|
614
|
+
if (!hit) {
|
|
615
|
+
reasons.push(rule.message || `missing file: ${rule.path}`);
|
|
616
|
+
} else {
|
|
617
|
+
evidence.file = hit.relativePath;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (rule.type === 'symbol' && rule.pattern) {
|
|
622
|
+
const regex = new RegExp(rule.pattern, 'i');
|
|
623
|
+
const hit = context.fileIndex.find((file) => regex.test(file.content));
|
|
624
|
+
if (!hit) {
|
|
625
|
+
reasons.push(rule.message || `missing symbol pattern: ${rule.pattern}`);
|
|
626
|
+
} else {
|
|
627
|
+
evidence.symbol = hit.relativePath;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (rule.type === 'artifact' && rule.path) {
|
|
632
|
+
const hit = context.fileIndex.find((file) => file.relativePath.startsWith(rule.path) || file.relativePath === rule.path);
|
|
633
|
+
if (!hit) {
|
|
634
|
+
reasons.push(rule.message || `missing artifact: ${rule.path}`);
|
|
635
|
+
} else {
|
|
636
|
+
evidence.artifact = hit.relativePath;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (rule.type === 'test' && context.testFrameworks.length === 0) {
|
|
641
|
+
reasons.push(rule.message || 'test framework not detected');
|
|
521
642
|
}
|
|
522
643
|
|
|
523
|
-
if (rule.type === '
|
|
524
|
-
const
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
reasons.push(rule.message || `missing symbol pattern: ${rule.pattern}`);
|
|
528
|
-
} else {
|
|
529
|
-
evidence.symbol = hit.relativePath;
|
|
644
|
+
if (rule.type === 'grant-evidence') {
|
|
645
|
+
const evidenceTargets = Array.isArray(rule.evidence) ? rule.evidence : [rule.evidence].filter(Boolean);
|
|
646
|
+
for (const key of evidenceTargets) {
|
|
647
|
+
evidence[key] = true;
|
|
530
648
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
evidence.
|
|
649
|
+
if (Array.isArray(rule.files) && rule.files.length > 0) {
|
|
650
|
+
evidence.files = rule.files;
|
|
651
|
+
}
|
|
652
|
+
if (Array.isArray(rule.symbols) && rule.symbols.length > 0) {
|
|
653
|
+
evidence.symbols = rule.symbols;
|
|
654
|
+
}
|
|
655
|
+
if (Array.isArray(rule.codeFiles) && rule.codeFiles.length > 0) {
|
|
656
|
+
evidence.codeFiles = rule.codeFiles;
|
|
657
|
+
evidence.code = true;
|
|
658
|
+
}
|
|
659
|
+
if (Array.isArray(rule.testFiles) && rule.testFiles.length > 0) {
|
|
660
|
+
evidence.testFiles = rule.testFiles;
|
|
661
|
+
evidence.test = true;
|
|
662
|
+
}
|
|
663
|
+
if (Array.isArray(rule.artifactFiles) && rule.artifactFiles.length > 0) {
|
|
664
|
+
evidence.artifactFiles = rule.artifactFiles;
|
|
665
|
+
evidence.artifact = true;
|
|
539
666
|
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (rule.type === 'test' && context.testFrameworks.length === 0) {
|
|
543
|
-
reasons.push(rule.message || 'test framework not detected');
|
|
544
667
|
}
|
|
545
668
|
|
|
546
669
|
return {
|
|
547
670
|
passed: reasons.length === 0,
|
|
548
671
|
reasons,
|
|
549
|
-
evidence
|
|
672
|
+
evidence,
|
|
673
|
+
overrideResult: rule.overrideResult === true
|
|
550
674
|
};
|
|
551
675
|
}
|
|
552
|
-
|
|
676
|
+
|
|
553
677
|
function buildValidationContext(projectRoot, config, plugins) {
|
|
554
678
|
const files = walkFiles(projectRoot);
|
|
555
|
-
const fileIndex = readFileIndex(projectRoot, files);
|
|
556
|
-
const testFrameworks = detectTestFrameworks(projectRoot, files);
|
|
557
|
-
|
|
558
|
-
return {
|
|
559
|
-
projectRoot,
|
|
560
|
-
config,
|
|
561
|
-
plugins,
|
|
562
|
-
files,
|
|
563
|
-
fileIndex,
|
|
564
|
-
testFrameworks
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function validateTask(task, context, config, plugins) {
|
|
569
|
-
const pathHints = extractExplicitPaths(task.text);
|
|
570
|
-
const standaloneFilenames = extractStandaloneFilenames(task.text);
|
|
571
|
-
const symbolHints = extractSymbolHints(task.text);
|
|
572
|
-
|
|
573
|
-
const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
|
|
574
|
-
const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
|
|
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.
|
|
679
|
+
const fileIndex = readFileIndex(projectRoot, files, config);
|
|
680
|
+
const testFrameworks = detectTestFrameworks(projectRoot, files);
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
projectRoot,
|
|
684
|
+
config,
|
|
685
|
+
plugins,
|
|
686
|
+
files,
|
|
687
|
+
fileIndex,
|
|
688
|
+
testFrameworks
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function validateTask(task, context, config, plugins) {
|
|
693
|
+
const pathHints = extractExplicitPaths(task.text);
|
|
694
|
+
const standaloneFilenames = extractStandaloneFilenames(task.text);
|
|
695
|
+
const symbolHints = extractSymbolHints(task.text);
|
|
696
|
+
|
|
697
|
+
const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
|
|
698
|
+
const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
|
|
699
|
+
// Combine path hints AND standalone filenames for token exclusion so that tokens
|
|
700
|
+
// derived from any referenced filename (e.g. "roadmap-skill" from
|
|
701
|
+
// "roadmap-skill.config.json") are excluded from code evidence scoring.
|
|
578
702
|
const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...standaloneFilenames]);
|
|
579
703
|
const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens);
|
|
704
|
+
const filesFromWeakPathTokens = findFilesByTaskPathTokens(task.text, context.fileIndex, pathDerivedTokens);
|
|
580
705
|
const filesFromTests = findTestEvidence(task.text, context.fileIndex);
|
|
581
706
|
const { files: filesFromArtifacts, heuristicArtifacts } = findArtifactEvidence(task.text, context.fileIndex);
|
|
582
|
-
|
|
583
|
-
const structuralCheck = checkNamespaceStructuralEvidence(task.id, task.text, context.fileIndex);
|
|
584
|
-
|
|
585
|
-
const evidence = {
|
|
586
|
-
code: filesFromCode.length > 0 || filesFromSymbols.length > 0,
|
|
587
|
-
test: filesFromTests.length > 0,
|
|
588
|
-
artifact: filesFromArtifacts.length > 0,
|
|
589
|
-
files: filesFromPaths,
|
|
707
|
+
|
|
708
|
+
const structuralCheck = checkNamespaceStructuralEvidence(task.id, task.text, context.fileIndex);
|
|
709
|
+
|
|
710
|
+
const evidence = {
|
|
711
|
+
code: filesFromCode.length > 0 || filesFromSymbols.length > 0,
|
|
712
|
+
test: filesFromTests.length > 0,
|
|
713
|
+
artifact: filesFromArtifacts.length > 0,
|
|
714
|
+
files: filesFromPaths,
|
|
590
715
|
symbols: filesFromSymbols,
|
|
591
716
|
codeFiles: filesFromCode,
|
|
717
|
+
weakPathFiles: filesFromWeakPathTokens,
|
|
592
718
|
testFiles: filesFromTests,
|
|
593
|
-
artifactFiles: filesFromArtifacts,
|
|
594
|
-
heuristicArtifacts,
|
|
595
|
-
structuralEvidence: structuralCheck.applicable ? structuralCheck.passed : null,
|
|
596
|
-
structuralFiles: structuralCheck.structuralFiles,
|
|
597
|
-
};
|
|
598
|
-
|
|
599
|
-
const reasons = [];
|
|
600
|
-
if (pathHints.length > 0 && filesFromPaths.length === 0) {
|
|
601
|
-
reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
|
|
602
|
-
}
|
|
603
|
-
if (symbolHints.length > 0 && filesFromSymbols.length === 0) {
|
|
604
|
-
reasons.push(`missing symbol(s): ${symbolHints.join(', ')}`);
|
|
605
|
-
}
|
|
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
|
-
|
|
719
|
+
artifactFiles: filesFromArtifacts,
|
|
720
|
+
heuristicArtifacts,
|
|
721
|
+
structuralEvidence: structuralCheck.applicable ? structuralCheck.passed : null,
|
|
722
|
+
structuralFiles: structuralCheck.structuralFiles,
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const reasons = [];
|
|
726
|
+
if (pathHints.length > 0 && filesFromPaths.length === 0) {
|
|
727
|
+
reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
|
|
728
|
+
}
|
|
729
|
+
if (symbolHints.length > 0 && filesFromSymbols.length === 0) {
|
|
730
|
+
reasons.push(`missing symbol(s): ${symbolHints.join(', ')}`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Namespace-structural gate: for known namespaces, token overlap alone is insufficient.
|
|
734
|
+
// The task must have evidence files whose paths match the namespace pattern.
|
|
735
|
+
if (structuralCheck.applicable && !structuralCheck.passed) {
|
|
736
|
+
reasons.push(structuralCheck.reason || `no structural evidence for namespace "${extractTaskNamespace(task.id)}"`);
|
|
737
|
+
}
|
|
738
|
+
|
|
613
739
|
const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
|
|
614
|
-
|
|
740
|
+
const hasWeakEvidence = filesFromWeakPathTokens.length > 0;
|
|
741
|
+
if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable) {
|
|
615
742
|
reasons.push('no code, test, or artifact evidence found');
|
|
616
|
-
} else if (!hasEvidence && structuralCheck.applicable && structuralCheck.passed) {
|
|
743
|
+
} else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed) {
|
|
617
744
|
reasons.push('no code, test, or artifact evidence found');
|
|
618
745
|
}
|
|
619
746
|
|
|
620
|
-
const requiresTest = context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
|
|
747
|
+
const requiresTest = !task.noTest && context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
|
|
621
748
|
if (requiresTest && !evidence.test) {
|
|
622
749
|
reasons.push('missing test evidence');
|
|
623
750
|
}
|
|
624
|
-
|
|
751
|
+
|
|
625
752
|
const configuredRules = Array.isArray(config.validators) ? config.validators : [];
|
|
626
753
|
const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
|
|
754
|
+
let overrideResult = null;
|
|
627
755
|
for (const rule of [...configuredRules, ...pluginRules]) {
|
|
628
756
|
const ruleResult = evaluateRule(rule, task, context);
|
|
757
|
+
if (ruleResult.evidence && Object.keys(ruleResult.evidence).length > 0) {
|
|
758
|
+
Object.assign(evidence, mergeRuleEvidence(evidence, ruleResult.evidence));
|
|
759
|
+
}
|
|
629
760
|
if (!ruleResult.passed) {
|
|
630
761
|
reasons.push(...ruleResult.reasons);
|
|
631
762
|
}
|
|
763
|
+
if (ruleResult.overrideResult) {
|
|
764
|
+
overrideResult = ruleResult;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const hasStrongEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
|
|
769
|
+
if (hasStrongEvidence) {
|
|
770
|
+
const noEvidenceReason = 'no code, test, or artifact evidence found';
|
|
771
|
+
const idx = reasons.indexOf(noEvidenceReason);
|
|
772
|
+
if (idx >= 0) {
|
|
773
|
+
reasons.splice(idx, 1);
|
|
774
|
+
}
|
|
632
775
|
}
|
|
633
776
|
|
|
634
|
-
|
|
635
|
-
const attempted = hasEvidence || pathHints.length > 0 || symbolHints.length > 0;
|
|
777
|
+
let uniqueReasons = Array.from(new Set(reasons));
|
|
636
778
|
|
|
637
|
-
|
|
638
|
-
|
|
779
|
+
if (overrideResult) {
|
|
780
|
+
uniqueReasons = Array.isArray(overrideResult.reasons) ? Array.from(new Set(overrideResult.reasons)) : [];
|
|
781
|
+
}
|
|
639
782
|
|
|
640
|
-
|
|
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);
|
|
783
|
+
const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0;
|
|
643
784
|
|
|
785
|
+
const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
|
|
786
|
+
let confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
|
|
787
|
+
if (confidence === 'low' && hasWeakEvidence) {
|
|
788
|
+
confidence = 'low';
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// True when the only passing evidence is artifact/doc files and the task is not a doc task.
|
|
792
|
+
// Used by auditValidation to flag implementation tasks that pass solely via documentation.
|
|
793
|
+
const evidenceIsDocOnly = !evidence.code && !evidence.test && evidence.artifact && !isDocTask(task.text);
|
|
794
|
+
|
|
644
795
|
return {
|
|
645
796
|
taskId: task.id,
|
|
646
|
-
passed: uniqueReasons.length === 0,
|
|
797
|
+
passed: overrideResult ? overrideResult.passed !== false : uniqueReasons.length === 0,
|
|
647
798
|
confidence,
|
|
648
799
|
reasons: uniqueReasons,
|
|
649
|
-
evidence,
|
|
650
|
-
evidenceIsDocOnly,
|
|
651
|
-
requiresTest,
|
|
652
|
-
hasEvidence,
|
|
800
|
+
evidence,
|
|
801
|
+
evidenceIsDocOnly,
|
|
802
|
+
requiresTest,
|
|
803
|
+
hasEvidence: hasStrongEvidence || hasWeakEvidence,
|
|
653
804
|
attempted
|
|
654
805
|
};
|
|
655
806
|
}
|
|
656
|
-
|
|
657
|
-
function validateTasks(tasks, context, config, plugins) {
|
|
658
|
-
const result = {};
|
|
659
|
-
for (const task of tasks) {
|
|
660
|
-
result[task.id] = validateTask(task, context, config, plugins);
|
|
661
|
-
}
|
|
662
|
-
return result;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
function auditValidation(tasks, results) {
|
|
666
|
-
const checkedWithoutEvidence = [];
|
|
667
|
-
const readyButUnchecked = [];
|
|
668
|
-
const checkedWithWeakEvidence = [];
|
|
669
|
-
const documentationOnlyEvidenceForImplementation = [];
|
|
670
|
-
const checkedWithNoStructuralEvidence = [];
|
|
671
|
-
|
|
672
|
-
for (const task of tasks) {
|
|
673
|
-
const result = results[task.id];
|
|
674
|
-
if (!result) continue;
|
|
675
|
-
|
|
676
|
-
if (task.checked && !result.passed) {
|
|
677
|
-
checkedWithoutEvidence.push({ task, result });
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
if (!task.checked && result.passed) {
|
|
681
|
-
readyButUnchecked.push({ task, result });
|
|
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
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
return {
|
|
699
|
-
checkedWithoutEvidence,
|
|
700
|
-
readyButUnchecked,
|
|
701
|
-
checkedWithWeakEvidence,
|
|
702
|
-
documentationOnlyEvidenceForImplementation,
|
|
703
|
-
checkedWithNoStructuralEvidence,
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
function applyMinimumConfidence(results, minimumConfidence) {
|
|
708
|
-
const minRank = CONFIDENCE_RANK[minimumConfidence] ?? 0;
|
|
709
|
-
if (minRank === 0) return;
|
|
710
|
-
for (const result of Object.values(results)) {
|
|
711
|
-
if ((CONFIDENCE_RANK[result.confidence] ?? 0) < minRank) {
|
|
712
|
-
result.passed = false;
|
|
713
|
-
result.reasons = [
|
|
714
|
-
...result.reasons,
|
|
715
|
-
`validation confidence "${result.confidence}" is below required "${minimumConfidence}"`
|
|
716
|
-
];
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
module.exports = {
|
|
722
|
-
auditValidation,
|
|
723
|
-
buildValidationContext,
|
|
724
|
-
validateTask,
|
|
725
|
-
validateTasks,
|
|
726
|
-
CONFIDENCE_RANK,
|
|
727
|
-
applyMinimumConfidence,
|
|
728
|
-
extractTaskNamespace,
|
|
729
|
-
isAcceptanceCriteria,
|
|
730
|
-
};
|
|
807
|
+
|
|
808
|
+
function validateTasks(tasks, context, config, plugins) {
|
|
809
|
+
const result = {};
|
|
810
|
+
for (const task of tasks) {
|
|
811
|
+
result[task.id] = validateTask(task, context, config, plugins);
|
|
812
|
+
}
|
|
813
|
+
return result;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function auditValidation(tasks, results) {
|
|
817
|
+
const checkedWithoutEvidence = [];
|
|
818
|
+
const readyButUnchecked = [];
|
|
819
|
+
const checkedWithWeakEvidence = [];
|
|
820
|
+
const documentationOnlyEvidenceForImplementation = [];
|
|
821
|
+
const checkedWithNoStructuralEvidence = [];
|
|
822
|
+
|
|
823
|
+
for (const task of tasks) {
|
|
824
|
+
const result = results[task.id];
|
|
825
|
+
if (!result) continue;
|
|
826
|
+
|
|
827
|
+
if (task.checked && !result.passed) {
|
|
828
|
+
checkedWithoutEvidence.push({ task, result });
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (!task.checked && result.passed) {
|
|
832
|
+
readyButUnchecked.push({ task, result });
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (task.checked && result.passed && result.confidence === 'low') {
|
|
836
|
+
checkedWithWeakEvidence.push({ task, result });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (task.checked && result.passed && result.evidenceIsDocOnly) {
|
|
840
|
+
documentationOnlyEvidenceForImplementation.push({ task, result });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Checked task that failed specifically because structural evidence is missing.
|
|
844
|
+
if (task.checked && !result.passed && result.evidence.structuralEvidence === false) {
|
|
845
|
+
checkedWithNoStructuralEvidence.push({ task, result });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
checkedWithoutEvidence,
|
|
851
|
+
readyButUnchecked,
|
|
852
|
+
checkedWithWeakEvidence,
|
|
853
|
+
documentationOnlyEvidenceForImplementation,
|
|
854
|
+
checkedWithNoStructuralEvidence,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function applyMinimumConfidence(results, minimumConfidence) {
|
|
859
|
+
const minRank = CONFIDENCE_RANK[minimumConfidence] ?? 0;
|
|
860
|
+
if (minRank === 0) return;
|
|
861
|
+
for (const result of Object.values(results)) {
|
|
862
|
+
if ((CONFIDENCE_RANK[result.confidence] ?? 0) < minRank) {
|
|
863
|
+
result.passed = false;
|
|
864
|
+
result.reasons = [
|
|
865
|
+
...result.reasons,
|
|
866
|
+
`validation confidence "${result.confidence}" is below required "${minimumConfidence}"`
|
|
867
|
+
];
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
module.exports = {
|
|
873
|
+
auditValidation,
|
|
874
|
+
buildValidationContext,
|
|
875
|
+
validateTask,
|
|
876
|
+
validateTasks,
|
|
877
|
+
CONFIDENCE_RANK,
|
|
878
|
+
applyMinimumConfidence,
|
|
879
|
+
extractTaskNamespace,
|
|
880
|
+
isAcceptanceCriteria,
|
|
881
|
+
};
|