roadmapsmith 0.9.3 → 0.9.5

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.
@@ -1,83 +1,83 @@
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
14
  const TRANSLATION_DIR_SEGMENTS = ['locale', 'locales', 'i18n', 'translations'];
15
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).
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).
81
81
  function isFixturePath(relativePath) {
82
82
  return /(?:^|[/\\])fixtures[/\\]/.test(relativePath);
83
83
  }
@@ -131,7 +131,7 @@ function isMostlyUiStrings(content) {
131
131
  const stringLikeLines = lines.filter((line) => /^['"`][^'"`]{1,200}['"`],?$/.test(line) || /^[A-Za-z0-9_.-]+\s*:\s*['"`][^'"`]{1,200}['"`],?$/.test(line)).length;
132
132
  return stringLikeLines / lines.length > 0.8;
133
133
  }
134
-
134
+
135
135
  function readFileIndex(projectRoot, files, config) {
136
136
  const index = [];
137
137
  for (const relativePath of files) {
@@ -141,13 +141,13 @@ function readFileIndex(projectRoot, files, config) {
141
141
 
142
142
  const absolutePath = path.resolve(projectRoot, relativePath);
143
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');
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
151
  } catch {
152
152
  continue;
153
153
  }
@@ -158,159 +158,159 @@ function readFileIndex(projectRoot, files, config) {
158
158
 
159
159
  index.push({
160
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
-
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
+
300
300
  function findFilesBySymbols(symbolHints, fileIndex) {
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));
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));
314
314
  }
315
315
 
316
316
  function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new Set()) {
@@ -333,6 +333,38 @@ function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new
333
333
  return Array.from(matches).sort((left, right) => left.localeCompare(right));
334
334
  }
335
335
 
336
+ function extractTaskEvidenceTokens(taskText, pathDerivedTokens = new Set()) {
337
+ return tokenize(taskText)
338
+ .filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/') && !pathDerivedTokens.has(token))
339
+ .slice(0, 8);
340
+ }
341
+
342
+ function findWeakPathContentSpecificTokens(taskText, fileIndex, weakPathFiles, pathDerivedTokens = new Set()) {
343
+ const tokens = extractTaskEvidenceTokens(taskText, pathDerivedTokens);
344
+ if (tokens.length === 0 || weakPathFiles.length === 0) return [];
345
+
346
+ const weakFiles = new Set(weakPathFiles);
347
+ const matches = new Set();
348
+ for (const file of fileIndex) {
349
+ if (!weakFiles.has(file.relativePath) || !CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
350
+ continue;
351
+ }
352
+
353
+ const normalizedPath = normalizePathForMatch(file.relativePath);
354
+ const lowered = file.content.toLowerCase();
355
+ for (const token of tokens) {
356
+ if (normalizedPath.includes(token)) {
357
+ continue;
358
+ }
359
+ if (lowered.includes(token)) {
360
+ matches.add(token);
361
+ }
362
+ }
363
+ }
364
+
365
+ return Array.from(matches).sort((left, right) => left.localeCompare(right));
366
+ }
367
+
336
368
  function mergeRuleEvidence(baseEvidence, ruleEvidence) {
337
369
  if (!ruleEvidence || typeof ruleEvidence !== 'object') return baseEvidence;
338
370
  const merged = { ...baseEvidence };
@@ -352,249 +384,295 @@ function mergeRuleEvidence(baseEvidence, ruleEvidence) {
352
384
 
353
385
  return merged;
354
386
  }
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
-
387
+
388
+ // Tokens extracted from a referenced file path (e.g. "roadmap-skill" from
389
+ // "roadmap-skill.config.json") must not be reused as code evidence signals.
390
+ // Those tokens appear in any file that mentions the same path — creating circular
391
+ // vocabulary where a task about "X in path/to/file" passes because the source
392
+ // code references the same path for unrelated reasons.
393
+ function extractPathDerivedTokens(pathHints) {
394
+ const tokens = new Set();
395
+ for (const hint of pathHints) {
396
+ // Char-split: "roadmap-skill.config.json" → ["roadmap", "skill", "config", "json"]
397
+ const parts = hint.replace(/[.\-_/\\]/g, ' ').toLowerCase().split(/\s+/).filter(Boolean);
398
+ for (const part of parts) {
399
+ if (part.length >= 3) tokens.add(part);
400
+ }
401
+ // Tokenizer-split: also adds compound tokens the char-split misses, e.g. "roadmap-skill"
402
+ // (the tokenizer preserves hyphens in identifiers; the char-split strips them).
403
+ for (const token of tokenize(hint)) {
404
+ if (token.length >= 3) tokens.add(token);
405
+ }
406
+ }
407
+ return tokens;
408
+ }
409
+
410
+ function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
411
+ const tokens = extractTaskEvidenceTokens(taskText, pathDerivedTokens);
412
+ if (tokens.length === 0) {
413
+ return [];
414
+ }
415
+
416
+ const matches = [];
417
+ for (const file of fileIndex) {
418
+ if (!CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
419
+ continue;
420
+ }
421
+
422
+ let score = 0;
423
+ const lowered = file.content.toLowerCase();
424
+ for (const token of tokens) {
425
+ if (token.length < 3) {
426
+ continue;
427
+ }
428
+ if (lowered.includes(token)) {
429
+ score += 1;
430
+ }
431
+ }
432
+
433
+ // Require more matches proportional to how many specific tokens the task has.
434
+ // Tasks with 4+ meaningful tokens need 3 files to match to prevent vocabulary overlap.
435
+ const threshold = tokens.length >= 4 ? 3 : tokens.length >= 2 ? 2 : 1;
436
+ if (score >= threshold) {
437
+ matches.push(file.relativePath);
438
+ }
439
+ }
440
+
441
+ return matches.slice(0, 20);
442
+ }
443
+
444
+ function normalizeReferencedPath(rawPath) {
445
+ return String(rawPath || '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
446
+ }
447
+
448
+ function referencedPathMatches(readRef, referencedPath) {
449
+ const normalizedRef = normalizeReferencedPath(readRef);
450
+ const normalizedHint = normalizeReferencedPath(referencedPath);
451
+ if (!normalizedRef || !normalizedHint) return false;
452
+ if (normalizedRef === normalizedHint || normalizedRef.endsWith('/' + normalizedHint)) {
453
+ return true;
454
+ }
455
+ return path.basename(normalizedRef) === normalizedHint || normalizedRef === path.basename(normalizedHint);
456
+ }
457
+
458
+ function extractTestReadReferences(content) {
459
+ const refs = [];
460
+ const lines = String(content || '').split(/\r?\n/);
461
+ for (const line of lines) {
462
+ if (!/\b(?:fs\.)?readFile(?:Sync)?\s*\(/.test(line)) {
463
+ continue;
464
+ }
465
+ const stringLiterals = line.match(/['"`]([^'"`]+)['"`]/g) || [];
466
+ for (const literal of stringLiterals) {
467
+ const value = literal.slice(1, -1);
468
+ if (hasKnownFileExtension(value) || value.includes('/') || value.includes('\\')) {
469
+ refs.push(value);
470
+ }
471
+ }
472
+ }
473
+ return refs;
474
+ }
475
+
476
+ function findTestEvidence(taskText, fileIndex, referencedPaths = []) {
477
+ const tokens = tokenize(taskText)
478
+ .filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/'))
479
+ .slice(0, 8);
480
+
481
+ const pathRefs = Array.from(new Set(referencedPaths)).filter(Boolean);
482
+ if (tokens.length === 0 && pathRefs.length === 0) return [];
483
+
484
+ // Only tokens of length >= 4 are used for import-reference matching.
485
+ // Very short tokens (e.g. "app", "web") are too generic: they appear as substrings in
486
+ // many import paths that have nothing to do with the feature being validated.
487
+ // The single-short-token fallback below handles the narrow case of one-word module names.
488
+ const importTokens = tokens.filter((token) => token.length >= 4);
489
+
490
+ const matches = [];
491
+
492
+ for (const file of fileIndex) {
493
+ if (!file.isTestFile) continue;
494
+
495
+ // A test file counts as evidence only when it imports a module whose path contains
496
+ // one of the task's meaningful tokens. Content-keyword matching is intentionally absent:
497
+ // test content (descriptions, literals) can contain future-task vocabulary,
498
+ // producing self-referential false positives.
499
+ //
500
+ // Trailing slashes are NOT stripped: "app/" is a directory reference, not a module name.
501
+ // "../src/app" (a real import) does not contain the string "app/" so it won't match.
502
+ const importRefs = (
503
+ file.content.match(/require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)|from\s+['"`]([^'"`]+)['"`]/g) || []
504
+ ).join(' ').toLowerCase();
505
+
506
+ if (importTokens.length > 0 && importTokens.some((token) => importRefs.includes(token))) {
507
+ matches.push(file.relativePath);
508
+ continue;
509
+ }
510
+
511
+ // Narrow fallback: single very-short token (e.g. "app", "cli").
512
+ // Import paths for these are too short to distinguish reliably, so fall back to a
513
+ // content match — but only when there is exactly one such token (no multi-token dilution).
514
+ if (tokens.length === 1 && tokens[0].length < 4) {
515
+ const lowered = file.content.toLowerCase();
516
+ if (lowered.includes(tokens[0])) {
517
+ matches.push(file.relativePath);
518
+ continue;
519
+ }
520
+ }
521
+
522
+ if (pathRefs.length > 0) {
523
+ const readRefs = extractTestReadReferences(file.content);
524
+ if (readRefs.some((readRef) => pathRefs.some((pathRef) => referencedPathMatches(readRef, pathRef)))) {
525
+ matches.push(file.relativePath);
526
+ }
527
+ }
528
+ }
529
+
530
+ return matches.slice(0, 20);
531
+ }
532
+
533
+ function findArtifactEvidence(taskText, fileIndex) {
534
+ const normalized = String(taskText).toLowerCase();
535
+ const files = [];
536
+ const heuristicArtifacts = [];
537
+
538
+ // Canonical file detection only applies to short tasks (≤8 words) that are about
539
+ // creating or referencing that specific file. Long sentences that merely MENTION
540
+ // "readme" or "security" in a policy/constraint context are excluded.
541
+ const wordCount = normalized.trim().split(/\s+/).length;
542
+ if (wordCount <= 8) {
543
+ for (const [keyword, filename] of Object.entries(CANONICAL_FILES)) {
544
+ // Use hyphen-aware word boundaries: "security-headers" must not match "security".
545
+ if (new RegExp(`(?<![a-z-])${keyword}(?![a-z-])`).test(normalized)) {
546
+ const hit = fileIndex.find(
547
+ (f) => f.relativePath === filename || f.relativePath.endsWith('/' + filename)
548
+ );
549
+ if (hit) {
550
+ files.push(hit.relativePath);
551
+ heuristicArtifacts.push(hit.relativePath);
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ if (!isDocTask(taskText)) {
558
+ return { files, heuristicArtifacts };
559
+ }
560
+
561
+ const artifactPatterns = [
562
+ /^README\.md$/i,
563
+ /^CHANGELOG\.md$/i,
564
+ /^docs\//i,
565
+ /^artifacts\//i,
566
+ /^dist\//i,
567
+ /^build\//i
568
+ ];
569
+
570
+ for (const file of fileIndex) {
571
+ if (artifactPatterns.some((pattern) => pattern.test(file.relativePath)) && !files.includes(file.relativePath)) {
572
+ files.push(file.relativePath);
573
+ }
574
+ }
575
+
576
+ return { files: files.slice(0, 20), heuristicArtifacts };
577
+ }
578
+
579
+ function extractTaskNamespace(taskId) {
580
+ if (!taskId) return null;
581
+ const match = String(taskId).match(/^([a-z][a-z0-9]*)-/);
582
+ return match ? match[1] : null;
583
+ }
584
+
585
+ function isAcceptanceCriteria(taskId) {
586
+ return /ph\d+[_-]st\d+[_-]exit/.test(String(taskId || ''));
587
+ }
588
+
589
+ // Gate: returns { applicable, passed, structuralFiles, reason }.
590
+ // For namespaces with a defined structural pattern:
591
+ // 1. If no files in fileIndex match the pattern → immediate fail.
592
+ // 2. For acceptance-criteria tasks (phN-stN-exit IDs): path match alone is enough.
593
+ // 3. For implementation tasks: feature tokens from task text must score ≥ ceil(n/2)
594
+ // against namespace-matched files, preventing vocabulary overlap from generic
595
+ // infrastructure code (io.js, generator/index.js) from serving as evidence.
596
+ function checkNamespaceStructuralEvidence(taskId, taskText, fileIndex) {
597
+ const namespace = extractTaskNamespace(taskId);
598
+ if (!namespace || !NAMESPACE_STRUCTURAL_PATTERNS[namespace]) {
599
+ return { applicable: false, passed: true, structuralFiles: [], reason: null };
600
+ }
601
+
602
+ const predicate = NAMESPACE_STRUCTURAL_PATTERNS[namespace];
603
+ const namespaceFiles = fileIndex.filter((f) => predicate(f.relativePath));
604
+
605
+ if (namespaceFiles.length === 0) {
606
+ return {
607
+ applicable: true,
608
+ passed: false,
609
+ structuralFiles: [],
610
+ reason: `namespace "${namespace}" has no implementation files`,
611
+ };
612
+ }
613
+
614
+ const featureTokens = tokenize(taskText)
615
+ .filter((t) => t.length >= 4 && !GENERIC_TASK_TOKENS.has(t) && !t.endsWith('/'))
616
+ .slice(0, 8);
617
+
618
+ if (featureTokens.length === 0) {
619
+ return {
620
+ applicable: true,
621
+ passed: true,
622
+ structuralFiles: namespaceFiles.map((f) => f.relativePath),
623
+ reason: null,
624
+ };
625
+ }
626
+
627
+ let bestScore = 0;
628
+ for (const nsFile of namespaceFiles) {
629
+ const lowered = nsFile.content.toLowerCase();
630
+ let score = 0;
631
+ for (const token of featureTokens) {
632
+ if (lowered.includes(token)) score++;
633
+ }
634
+ if (score > bestScore) bestScore = score;
635
+ }
636
+
637
+ const threshold = Math.max(1, Math.ceil(featureTokens.length / 2));
638
+ if (bestScore >= threshold) {
639
+ return {
640
+ applicable: true,
641
+ passed: true,
642
+ structuralFiles: namespaceFiles.map((f) => f.relativePath),
643
+ reason: null,
644
+ };
645
+ }
646
+
647
+ return {
648
+ applicable: true,
649
+ passed: false,
650
+ structuralFiles: namespaceFiles.map((f) => f.relativePath),
651
+ reason: `structural token score ${bestScore}/${threshold} in "${namespace}" files — token overlap insufficient`,
652
+ };
653
+ }
654
+
584
655
  function evaluateRule(rule, task, context) {
585
656
  if (!rule) {
586
657
  return { passed: true, reasons: [], evidence: {}, overrideResult: false };
587
658
  }
588
-
589
- if (rule.when) {
590
- const regexp = new RegExp(rule.when, 'i');
659
+
660
+ if (rule.when) {
661
+ const regexp = new RegExp(rule.when, 'i');
591
662
  if (!regexp.test(task.text)) {
592
663
  return { passed: true, reasons: [], evidence: {}, overrideResult: false };
593
664
  }
594
665
  }
595
-
596
- if (typeof rule.check === 'function') {
597
- const custom = rule.check(task, context);
666
+
667
+ if (rule.whenId) {
668
+ const regexp = new RegExp(rule.whenId, 'i');
669
+ if (!regexp.test(task.id)) {
670
+ return { passed: true, reasons: [], evidence: {}, overrideResult: false };
671
+ }
672
+ }
673
+
674
+ if (typeof rule.check === 'function') {
675
+ const custom = rule.check(task, context);
598
676
  if (!custom) {
599
677
  return { passed: true, reasons: [], evidence: {}, overrideResult: false };
600
678
  }
@@ -605,38 +683,38 @@ function evaluateRule(rule, task, context) {
605
683
  overrideResult: rule.overrideResult === true || custom.overrideResult === true
606
684
  };
607
685
  }
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
-
686
+
687
+ const reasons = [];
688
+ const evidence = {};
689
+
690
+ if (rule.type === 'file-exists' && rule.path) {
691
+ const hit = context.fileIndex.find((file) => file.relativePath === rule.path || file.relativePath.endsWith(rule.path));
692
+ if (!hit) {
693
+ reasons.push(rule.message || `missing file: ${rule.path}`);
694
+ } else {
695
+ evidence.file = hit.relativePath;
696
+ }
697
+ }
698
+
699
+ if (rule.type === 'symbol' && rule.pattern) {
700
+ const regex = new RegExp(rule.pattern, 'i');
701
+ const hit = context.fileIndex.find((file) => regex.test(file.content));
702
+ if (!hit) {
703
+ reasons.push(rule.message || `missing symbol pattern: ${rule.pattern}`);
704
+ } else {
705
+ evidence.symbol = hit.relativePath;
706
+ }
707
+ }
708
+
709
+ if (rule.type === 'artifact' && rule.path) {
710
+ const hit = context.fileIndex.find((file) => file.relativePath.startsWith(rule.path) || file.relativePath === rule.path);
711
+ if (!hit) {
712
+ reasons.push(rule.message || `missing artifact: ${rule.path}`);
713
+ } else {
714
+ evidence.artifact = hit.relativePath;
715
+ }
716
+ }
717
+
640
718
  if (rule.type === 'test' && context.testFrameworks.length === 0) {
641
719
  reasons.push(rule.message || 'test framework not detected');
642
720
  }
@@ -673,82 +751,86 @@ function evaluateRule(rule, task, context) {
673
751
  overrideResult: rule.overrideResult === true
674
752
  };
675
753
  }
676
-
754
+
677
755
  function buildValidationContext(projectRoot, config, plugins) {
678
756
  const files = walkFiles(projectRoot);
679
757
  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.
758
+ const testFrameworks = detectTestFrameworks(projectRoot, files);
759
+
760
+ return {
761
+ projectRoot,
762
+ config,
763
+ plugins,
764
+ files,
765
+ fileIndex,
766
+ testFrameworks
767
+ };
768
+ }
769
+
770
+ function validateTask(task, context, config, plugins) {
771
+ const pathHints = extractExplicitPaths(task.text);
772
+ const standaloneFilenames = extractStandaloneFilenames(task.text);
773
+ const symbolHints = extractSymbolHints(task.text);
774
+
775
+ const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
776
+ const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
777
+ // Combine path hints AND standalone filenames for token exclusion so that tokens
778
+ // derived from any referenced filename (e.g. "roadmap-skill" from
779
+ // "roadmap-skill.config.json") are excluded from code evidence scoring.
702
780
  const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...standaloneFilenames]);
703
781
  const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens);
704
782
  const filesFromWeakPathTokens = findFilesByTaskPathTokens(task.text, context.fileIndex, pathDerivedTokens);
705
- const filesFromTests = findTestEvidence(task.text, context.fileIndex);
783
+ const weakPathContentTokens = findWeakPathContentSpecificTokens(task.text, context.fileIndex, filesFromWeakPathTokens, pathDerivedTokens);
784
+ const filesFromTests = findTestEvidence(task.text, context.fileIndex, [...pathHints, ...standaloneFilenames]);
706
785
  const { files: filesFromArtifacts, heuristicArtifacts } = findArtifactEvidence(task.text, context.fileIndex);
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,
786
+
787
+ const structuralCheck = checkNamespaceStructuralEvidence(task.id, task.text, context.fileIndex);
788
+
789
+ const evidence = {
790
+ code: filesFromCode.length > 0 || filesFromSymbols.length > 0,
791
+ test: filesFromTests.length > 0,
792
+ artifact: filesFromArtifacts.length > 0,
793
+ files: filesFromPaths,
715
794
  symbols: filesFromSymbols,
716
795
  codeFiles: filesFromCode,
717
796
  weakPathFiles: filesFromWeakPathTokens,
797
+ weakPathContentTokens,
718
798
  testFiles: filesFromTests,
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
-
799
+ artifactFiles: filesFromArtifacts,
800
+ heuristicArtifacts,
801
+ structuralEvidence: structuralCheck.applicable ? structuralCheck.passed : null,
802
+ structuralFiles: structuralCheck.structuralFiles,
803
+ };
804
+
805
+ const reasons = [];
806
+ if (pathHints.length > 0 && filesFromPaths.length === 0) {
807
+ reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
808
+ }
809
+ if (symbolHints.length > 0 && filesFromSymbols.length === 0) {
810
+ reasons.push(`missing symbol(s): ${symbolHints.join(', ')}`);
811
+ }
812
+
813
+ // Namespace-structural gate: for known namespaces, token overlap alone is insufficient.
814
+ // The task must have evidence files whose paths match the namespace pattern.
815
+ if (structuralCheck.applicable && !structuralCheck.passed) {
816
+ reasons.push(structuralCheck.reason || `no structural evidence for namespace "${extractTaskNamespace(task.id)}"`);
817
+ }
818
+
739
819
  const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
740
820
  const hasWeakEvidence = filesFromWeakPathTokens.length > 0;
741
821
  if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable) {
742
822
  reasons.push('no code, test, or artifact evidence found');
743
823
  } else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed) {
744
824
  reasons.push('no code, test, or artifact evidence found');
825
+ } else if (!hasEvidence && hasWeakEvidence) {
826
+ if (weakPathContentTokens.length === 0) {
827
+ reasons.push('weak path-only evidence lacks content-specific token match');
828
+ } else {
829
+ reasons.push('weak path-token evidence lacks strong code, test, or artifact evidence');
830
+ }
745
831
  }
746
832
 
747
833
  const requiresTest = !task.noTest && context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
748
- if (requiresTest && !evidence.test) {
749
- reasons.push('missing test evidence');
750
- }
751
-
752
834
  const configuredRules = Array.isArray(config.validators) ? config.validators : [];
753
835
  const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
754
836
  let overrideResult = null;
@@ -774,6 +856,10 @@ function validateTask(task, context, config, plugins) {
774
856
  }
775
857
  }
776
858
 
859
+ if (requiresTest && !evidence.test) {
860
+ reasons.push('missing test evidence');
861
+ }
862
+
777
863
  let uniqueReasons = Array.from(new Set(reasons));
778
864
 
779
865
  if (overrideResult) {
@@ -787,95 +873,95 @@ function validateTask(task, context, config, plugins) {
787
873
  if (confidence === 'low' && hasWeakEvidence) {
788
874
  confidence = 'low';
789
875
  }
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
-
876
+
877
+ // True when the only passing evidence is artifact/doc files and the task is not a doc task.
878
+ // Used by auditValidation to flag implementation tasks that pass solely via documentation.
879
+ const evidenceIsDocOnly = !evidence.code && !evidence.test && evidence.artifact && !isDocTask(task.text);
880
+
795
881
  return {
796
882
  taskId: task.id,
797
883
  passed: overrideResult ? overrideResult.passed !== false : uniqueReasons.length === 0,
798
884
  confidence,
799
885
  reasons: uniqueReasons,
800
- evidence,
801
- evidenceIsDocOnly,
802
- requiresTest,
886
+ evidence,
887
+ evidenceIsDocOnly,
888
+ requiresTest,
803
889
  hasEvidence: hasStrongEvidence || hasWeakEvidence,
804
890
  attempted
805
891
  };
806
892
  }
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
- };
893
+
894
+ function validateTasks(tasks, context, config, plugins) {
895
+ const result = {};
896
+ for (const task of tasks) {
897
+ result[task.id] = validateTask(task, context, config, plugins);
898
+ }
899
+ return result;
900
+ }
901
+
902
+ function auditValidation(tasks, results) {
903
+ const checkedWithoutEvidence = [];
904
+ const readyButUnchecked = [];
905
+ const checkedWithWeakEvidence = [];
906
+ const documentationOnlyEvidenceForImplementation = [];
907
+ const checkedWithNoStructuralEvidence = [];
908
+
909
+ for (const task of tasks) {
910
+ const result = results[task.id];
911
+ if (!result) continue;
912
+
913
+ if (task.checked && !result.passed) {
914
+ checkedWithoutEvidence.push({ task, result });
915
+ }
916
+
917
+ if (!task.checked && result.passed) {
918
+ readyButUnchecked.push({ task, result });
919
+ }
920
+
921
+ if (task.checked && result.passed && result.confidence === 'low') {
922
+ checkedWithWeakEvidence.push({ task, result });
923
+ }
924
+
925
+ if (task.checked && result.passed && result.evidenceIsDocOnly) {
926
+ documentationOnlyEvidenceForImplementation.push({ task, result });
927
+ }
928
+
929
+ // Checked task that failed specifically because structural evidence is missing.
930
+ if (task.checked && !result.passed && result.evidence.structuralEvidence === false) {
931
+ checkedWithNoStructuralEvidence.push({ task, result });
932
+ }
933
+ }
934
+
935
+ return {
936
+ checkedWithoutEvidence,
937
+ readyButUnchecked,
938
+ checkedWithWeakEvidence,
939
+ documentationOnlyEvidenceForImplementation,
940
+ checkedWithNoStructuralEvidence,
941
+ };
942
+ }
943
+
944
+ function applyMinimumConfidence(results, minimumConfidence) {
945
+ const minRank = CONFIDENCE_RANK[minimumConfidence] ?? 0;
946
+ if (minRank === 0) return;
947
+ for (const result of Object.values(results)) {
948
+ if ((CONFIDENCE_RANK[result.confidence] ?? 0) < minRank) {
949
+ result.passed = false;
950
+ result.reasons = [
951
+ ...result.reasons,
952
+ `validation confidence "${result.confidence}" is below required "${minimumConfidence}"`
953
+ ];
954
+ }
955
+ }
956
+ }
957
+
958
+ module.exports = {
959
+ auditValidation,
960
+ buildValidationContext,
961
+ validateTask,
962
+ validateTasks,
963
+ CONFIDENCE_RANK,
964
+ applyMinimumConfidence,
965
+ extractTaskNamespace,
966
+ isAcceptanceCriteria,
967
+ };