roadmapsmith 0.9.28 → 0.9.30

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.
@@ -13,6 +13,8 @@ const CODE_EXTENSIONS = new Set([
13
13
  ]);
14
14
  const TRANSLATION_DIR_SEGMENTS = ['locale', 'locales', 'i18n', 'translations'];
15
15
  const DEFAULT_EXCLUDED_PATH_PREFIXES = ['.claude/', '.agent/', 'roadmap-skill/'];
16
+ const GENERATED_OUTPUT_PREFIXES = ['dist-electron/', 'dist/', 'build/', 'out/', '.next/', 'coverage/'];
17
+ const AUXILIARY_HEURISTIC_PATH_SEGMENTS = new Set(['scripts', 'tools', 'tooling', 'demo', 'demos']);
16
18
 
17
19
  // "docs" omitted from DOC_HINTS — it is a path prefix in scan tasks, not a doc-authoring keyword.
18
20
  const DOC_HINTS = ['readme', 'changelog', 'documentation', 'spec', 'diagram', 'runbook'];
@@ -151,6 +153,93 @@ function isMostlyUiStrings(content) {
151
153
  return stringLikeLines / lines.length > 0.8;
152
154
  }
153
155
 
156
+ function isGeneratedOutputPath(relativePath) {
157
+ const normalized = normalizePathForMatch(relativePath);
158
+ return GENERATED_OUTPUT_PREFIXES.some((prefix) => normalized.startsWith(prefix));
159
+ }
160
+
161
+ function splitNormalizedPathSegments(relativePath) {
162
+ return normalizePathForMatch(relativePath).split('/').filter(Boolean);
163
+ }
164
+
165
+ function isAuxiliaryHeuristicPath(relativePath) {
166
+ return splitNormalizedPathSegments(relativePath).some((segment) => AUXILIARY_HEURISTIC_PATH_SEGMENTS.has(segment));
167
+ }
168
+
169
+ function relativePathExplicitlyReferenced(relativePath, options = {}) {
170
+ const normalized = normalizeReferencedPath(relativePath);
171
+ const explicitPaths = Array.isArray(options.explicitPaths) ? options.explicitPaths : [];
172
+ const explicitFilenames = Array.isArray(options.explicitFilenames) ? options.explicitFilenames : [];
173
+ if (explicitPaths.some((entry) => referencedPathMatches(normalized, entry))) {
174
+ return true;
175
+ }
176
+
177
+ const baseName = path.basename(normalized);
178
+ return explicitFilenames.some((entry) => normalizeReferencedPath(entry) === baseName);
179
+ }
180
+
181
+ function shouldSkipHeuristicFile(relativePath, options = {}) {
182
+ return isAuxiliaryHeuristicPath(relativePath) && !relativePathExplicitlyReferenced(relativePath, options);
183
+ }
184
+
185
+ function authoredSiblingGroupKey(relativePath) {
186
+ const normalized = normalizePathForMatch(relativePath);
187
+ const ext = path.extname(normalized);
188
+ return ext ? normalized.slice(0, -ext.length) : normalized;
189
+ }
190
+
191
+ function authoredSiblingExtensionRank(relativePath) {
192
+ const ext = path.extname(String(relativePath || '')).toLowerCase();
193
+ switch (ext) {
194
+ case '.ts':
195
+ return 0;
196
+ case '.tsx':
197
+ return 1;
198
+ case '.jsx':
199
+ return 2;
200
+ case '.js':
201
+ return 3;
202
+ case '.mjs':
203
+ return 4;
204
+ case '.cjs':
205
+ return 5;
206
+ case '.py':
207
+ return 6;
208
+ default:
209
+ return 10;
210
+ }
211
+ }
212
+
213
+ function dedupeAuthoredCompiledSiblings(relativePaths, fileIndex) {
214
+ const indexedPaths = new Set(fileIndex.map((file) => file.relativePath));
215
+ const groups = new Map();
216
+ for (const relativePath of Array.from(new Set(relativePaths)).filter(Boolean)) {
217
+ if (!indexedPaths.has(relativePath)) {
218
+ continue;
219
+ }
220
+ const key = authoredSiblingGroupKey(relativePath);
221
+ const group = groups.get(key) || [];
222
+ group.push(relativePath);
223
+ groups.set(key, group);
224
+ }
225
+
226
+ const selected = [];
227
+ for (const group of groups.values()) {
228
+ group.sort((left, right) => {
229
+ const rankDelta = authoredSiblingExtensionRank(left) - authoredSiblingExtensionRank(right);
230
+ if (rankDelta !== 0) return rankDelta;
231
+ return left.localeCompare(right);
232
+ });
233
+ selected.push(group[0]);
234
+ }
235
+
236
+ return selected.sort((left, right) => left.localeCompare(right));
237
+ }
238
+
239
+ function finalizeHeuristicMatches(relativePaths, fileIndex, limit = 20) {
240
+ return dedupeAuthoredCompiledSiblings(relativePaths, fileIndex).slice(0, limit);
241
+ }
242
+
154
243
  function readFileIndex(projectRoot, files, config) {
155
244
  const index = [];
156
245
  for (const relativePath of files) {
@@ -180,6 +269,7 @@ function readFileIndex(projectRoot, files, config) {
180
269
  absolutePath,
181
270
  ext,
182
271
  content,
272
+ generatedOutput: isGeneratedOutputPath(relativePath),
183
273
  isTestFile: /(^|\/)(__tests__|tests)\//.test(relativePath) || /\.test\.|\.spec\.|_test\.go$/.test(relativePath)
184
274
  });
185
275
  }
@@ -626,23 +716,27 @@ function findFilesByPathHints(pathHints, pathHintResolver) {
626
716
  return Array.from(new Set(matches)).sort((left, right) => left.localeCompare(right));
627
717
  }
628
718
 
629
- function findFilesBySymbols(symbolHints, fileIndex) {
719
+ function findFilesBySymbols(symbolHints, fileIndex, heuristicOptions = {}) {
630
720
  const matches = new Set();
631
721
  for (const symbol of symbolHints) {
632
722
  const regex = new RegExp(`\\b${escapeRegExp(symbol)}\\b`, 'i');
633
723
  for (const file of fileIndex) {
724
+ if (file.generatedOutput) continue;
634
725
  if (!CODE_EXTENSIONS.has(file.ext)) {
635
726
  continue;
636
727
  }
728
+ if (shouldSkipHeuristicFile(file.relativePath, heuristicOptions)) {
729
+ continue;
730
+ }
637
731
  if (regex.test(file.content)) {
638
732
  matches.add(file.relativePath);
639
733
  }
640
734
  }
641
735
  }
642
- return Array.from(matches).sort((left, right) => left.localeCompare(right));
736
+ return finalizeHeuristicMatches(Array.from(matches), fileIndex);
643
737
  }
644
738
 
645
- function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new Set()) {
739
+ function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new Set(), heuristicOptions = {}) {
646
740
  const tokens = tokenize(taskText)
647
741
  .filter((token) => token.length >= 6 && !GENERIC_TASK_TOKENS.has(token) && !pathDerivedTokens.has(token))
648
742
  .slice(0, 10);
@@ -650,6 +744,10 @@ function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new
650
744
 
651
745
  const matches = new Set();
652
746
  for (const file of fileIndex) {
747
+ if (file.generatedOutput) continue;
748
+ if (shouldSkipHeuristicFile(file.relativePath, heuristicOptions)) {
749
+ continue;
750
+ }
653
751
  const pathSegments = normalizePathForMatch(file.relativePath).split('/').filter(Boolean);
654
752
  for (const token of tokens) {
655
753
  if (pathSegments.some((segment) => segment === token || segment.includes(token))) {
@@ -659,16 +757,16 @@ function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new
659
757
  }
660
758
  if (matches.size >= 20) break;
661
759
  }
662
- return Array.from(matches).sort((left, right) => left.localeCompare(right));
760
+ return finalizeHeuristicMatches(Array.from(matches), fileIndex);
663
761
  }
664
762
 
665
- function extractTaskEvidenceTokens(taskText, pathDerivedTokens = new Set()) {
763
+ function extractTaskEvidenceTokens(taskText, pathDerivedTokens = new Set(), minimumLength = 3) {
666
764
  return tokenize(taskText)
667
- .filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/') && !pathDerivedTokens.has(token))
765
+ .filter((token) => token.length >= minimumLength && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/') && !pathDerivedTokens.has(token))
668
766
  .slice(0, 8);
669
767
  }
670
768
 
671
- function findWeakPathContentSpecificTokens(taskText, fileIndex, weakPathFiles, pathDerivedTokens = new Set()) {
769
+ function findWeakPathContentSpecificTokens(taskText, fileIndex, weakPathFiles, pathDerivedTokens = new Set(), heuristicOptions = {}) {
672
770
  const tokens = extractTaskEvidenceTokens(taskText, pathDerivedTokens);
673
771
  if (tokens.length === 0 || weakPathFiles.length === 0) return [];
674
772
 
@@ -678,6 +776,9 @@ function findWeakPathContentSpecificTokens(taskText, fileIndex, weakPathFiles, p
678
776
  if (!weakFiles.has(file.relativePath) || !CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
679
777
  continue;
680
778
  }
779
+ if (shouldSkipHeuristicFile(file.relativePath, heuristicOptions)) {
780
+ continue;
781
+ }
681
782
 
682
783
  const normalizedPath = normalizePathForMatch(file.relativePath);
683
784
  const lowered = file.content.toLowerCase();
@@ -736,7 +837,7 @@ function extractPathDerivedTokens(pathHints) {
736
837
  return tokens;
737
838
  }
738
839
 
739
- function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
840
+ function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set(), heuristicOptions = {}) {
740
841
  const tokens = extractTaskEvidenceTokens(taskText, pathDerivedTokens);
741
842
  if (tokens.length === 0) {
742
843
  return [];
@@ -744,7 +845,10 @@ function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
744
845
 
745
846
  const matches = [];
746
847
  for (const file of fileIndex) {
747
- if (!CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
848
+ if (file.generatedOutput || !CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
849
+ continue;
850
+ }
851
+ if (shouldSkipHeuristicFile(file.relativePath, heuristicOptions)) {
748
852
  continue;
749
853
  }
750
854
 
@@ -767,7 +871,7 @@ function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
767
871
  }
768
872
  }
769
873
 
770
- return matches.slice(0, 20);
874
+ return finalizeHeuristicMatches(matches, fileIndex);
771
875
  }
772
876
 
773
877
  function normalizeReferencedPath(rawPath) {
@@ -802,7 +906,7 @@ function extractTestReadReferences(content) {
802
906
  return refs;
803
907
  }
804
908
 
805
- function findTestEvidence(taskText, fileIndex, referencedPaths = []) {
909
+ function findTestEvidence(taskText, fileIndex, referencedPaths = [], heuristicOptions = {}) {
806
910
  const tokens = tokenize(taskText)
807
911
  .filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/'))
808
912
  .slice(0, 8);
@@ -819,7 +923,10 @@ function findTestEvidence(taskText, fileIndex, referencedPaths = []) {
819
923
  const matches = [];
820
924
 
821
925
  for (const file of fileIndex) {
822
- if (!file.isTestFile) continue;
926
+ if (file.generatedOutput || !file.isTestFile) continue;
927
+ if (shouldSkipHeuristicFile(file.relativePath, heuristicOptions)) {
928
+ continue;
929
+ }
823
930
 
824
931
  // A test file counts as evidence only when it imports a module whose path contains
825
932
  // one of the task's meaningful tokens. Content-keyword matching is intentionally absent:
@@ -856,7 +963,7 @@ function findTestEvidence(taskText, fileIndex, referencedPaths = []) {
856
963
  }
857
964
  }
858
965
 
859
- return matches.slice(0, 20);
966
+ return finalizeHeuristicMatches(matches, fileIndex);
860
967
  }
861
968
 
862
969
  function findArtifactEvidence(taskText, fileIndex) {
@@ -897,6 +1004,7 @@ function findArtifactEvidence(taskText, fileIndex) {
897
1004
  ];
898
1005
 
899
1006
  for (const file of fileIndex) {
1007
+ if (file.generatedOutput) continue;
900
1008
  if (artifactPatterns.some((pattern) => pattern.test(file.relativePath)) && !files.includes(file.relativePath)) {
901
1009
  files.push(file.relativePath);
902
1010
  }
@@ -1273,7 +1381,399 @@ function evaluateRule(rule, task, context) {
1273
1381
  };
1274
1382
  }
1275
1383
 
1276
- function buildValidationContext(projectRoot, config, plugins) {
1384
+ function parseVerificationFields(text) {
1385
+ const fields = {};
1386
+ for (const part of String(text || '').split(';')) {
1387
+ const separator = part.indexOf('=');
1388
+ if (separator <= 0) continue;
1389
+ const key = part.slice(0, separator).trim().toLowerCase();
1390
+ let value = part.slice(separator + 1).trim();
1391
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
1392
+ value = value.slice(1, -1);
1393
+ }
1394
+ if (key && value) fields[key] = value;
1395
+ }
1396
+ return fields;
1397
+ }
1398
+
1399
+ function stripCodeComments(content) {
1400
+ const source = String(content || '');
1401
+ let result = '';
1402
+ let quote = null;
1403
+ let escaped = false;
1404
+ let lineHasContent = false;
1405
+
1406
+ for (let index = 0; index < source.length; index += 1) {
1407
+ const char = source[index];
1408
+ const next = source[index + 1];
1409
+
1410
+ if (quote) {
1411
+ result += char;
1412
+ if (escaped) {
1413
+ escaped = false;
1414
+ } else if (char === '\\') {
1415
+ escaped = true;
1416
+ } else if (char === quote) {
1417
+ quote = null;
1418
+ }
1419
+ if (char === '\n') lineHasContent = false;
1420
+ continue;
1421
+ }
1422
+
1423
+ if (char === '"' || char === "'" || char === '`') {
1424
+ quote = char;
1425
+ result += char;
1426
+ lineHasContent = true;
1427
+ continue;
1428
+ }
1429
+
1430
+ if (char === '/' && next === '*') {
1431
+ const closeIndex = source.indexOf('*/', index + 2);
1432
+ index = closeIndex < 0 ? source.length : closeIndex + 1;
1433
+ continue;
1434
+ }
1435
+
1436
+ if (char === '/' && next === '/') {
1437
+ const newlineIndex = source.indexOf('\n', index + 2);
1438
+ if (newlineIndex < 0) break;
1439
+ result += '\n';
1440
+ lineHasContent = false;
1441
+ index = newlineIndex;
1442
+ continue;
1443
+ }
1444
+
1445
+ if (char === '#' && !lineHasContent) {
1446
+ const newlineIndex = source.indexOf('\n', index + 1);
1447
+ if (newlineIndex < 0) break;
1448
+ result += '\n';
1449
+ lineHasContent = false;
1450
+ index = newlineIndex;
1451
+ continue;
1452
+ }
1453
+
1454
+ result += char;
1455
+ if (char === '\n') {
1456
+ lineHasContent = false;
1457
+ } else if (!/\s/.test(char)) {
1458
+ lineHasContent = true;
1459
+ }
1460
+ }
1461
+
1462
+ return result;
1463
+ }
1464
+
1465
+ function findIndexedFile(relativePath, context) {
1466
+ const normalized = normalizeReferencedPath(relativePath);
1467
+ return context.fileIndex.find((file) => !file.generatedOutput && (
1468
+ normalizeReferencedPath(file.relativePath) === normalized ||
1469
+ normalizeReferencedPath(file.relativePath).endsWith('/' + normalized)
1470
+ ));
1471
+ }
1472
+
1473
+ function readTestReportRecords(projectRoot, validationConfig) {
1474
+ const reports = Array.isArray(validationConfig && validationConfig.testReports)
1475
+ ? validationConfig.testReports
1476
+ : [];
1477
+ const records = [];
1478
+
1479
+ function visit(value, reportPath, mtimeMs) {
1480
+ if (!value || typeof value !== 'object') return;
1481
+ if (Array.isArray(value)) {
1482
+ value.forEach((entry) => visit(entry, reportPath, mtimeMs));
1483
+ return;
1484
+ }
1485
+ const status = String(value.status || value.state || value.result || '').toLowerCase();
1486
+ const file = value.file || value.filepath || value.testFile || value.nameFile;
1487
+ const name = value.fullName || value.fullname || value.name || value.title;
1488
+ if (file && name && /^(pass|passed|success)$/.test(status)) {
1489
+ records.push({ file: String(file), name: String(name), reportPath, mtimeMs });
1490
+ }
1491
+ Object.values(value).forEach((entry) => visit(entry, reportPath, mtimeMs));
1492
+ }
1493
+
1494
+ for (const report of reports) {
1495
+ if (!report || report.format !== 'vitest-json' || !report.path) continue;
1496
+ const reportPath = path.resolve(projectRoot, report.path);
1497
+ try {
1498
+ const payload = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
1499
+ const mtimeMs = fs.statSync(reportPath).mtimeMs;
1500
+ visit(payload, reportPath, mtimeMs);
1501
+ } catch {
1502
+ // A missing or malformed optional report is simply unavailable evidence.
1503
+ }
1504
+ }
1505
+ return records;
1506
+ }
1507
+
1508
+ function timestampIsFresh(timestamp, files) {
1509
+ const verifiedAt = Date.parse(timestamp || '');
1510
+ if (!Number.isFinite(verifiedAt)) return false;
1511
+ return files.every((file) => {
1512
+ try {
1513
+ return verifiedAt >= fs.statSync(file.absolutePath).mtimeMs;
1514
+ } catch {
1515
+ return false;
1516
+ }
1517
+ });
1518
+ }
1519
+
1520
+ function findFreshTestProof(task, fields, context) {
1521
+ const testFile = findIndexedFile(fields.test || fields.file, context);
1522
+ const sourceFile = fields.source ? findIndexedFile(fields.source, context) : null;
1523
+ const caseName = fields.case;
1524
+ if (!testFile || !caseName) return { passed: false, available: false };
1525
+ const freshnessFiles = [testFile, ...(sourceFile ? [sourceFile] : [])];
1526
+ const evidenceLines = Array.isArray(task.testEvidenceLines) ? task.testEvidenceLines : [];
1527
+ for (const line of evidenceLines) {
1528
+ const evidence = parseVerificationFields(line.text);
1529
+ if (String(evidence.status || '').toUpperCase() !== 'PASS') continue;
1530
+ if (normalizeReferencedPath(evidence.file) !== normalizeReferencedPath(testFile.relativePath)) continue;
1531
+ if (evidence.case !== caseName) continue;
1532
+ if (timestampIsFresh(evidence.verifiedat, freshnessFiles)) {
1533
+ return { passed: true, available: true };
1534
+ }
1535
+ return { passed: false, available: true, stale: true };
1536
+ }
1537
+ for (const record of context.testReportRecords || []) {
1538
+ if (!referencedPathMatches(record.file, testFile.relativePath) || !record.name.includes(caseName)) continue;
1539
+ if (record.mtimeMs >= Math.max(...freshnessFiles.map((file) => fs.statSync(file.absolutePath).mtimeMs))) {
1540
+ return {
1541
+ passed: true,
1542
+ available: true,
1543
+ generatedEvidence: `file=${testFile.relativePath}; case=${caseName}; status=PASS; verifiedAt=${new Date(record.mtimeMs).toISOString()}`
1544
+ };
1545
+ }
1546
+ return { passed: false, available: true, stale: true };
1547
+ }
1548
+ return { passed: false, available: false };
1549
+ }
1550
+
1551
+ function testCoversEndpoint(testFile, route, context) {
1552
+ const normalizedRoute = String(route).replace(/\[([^\]]+)\]/g, '$1').toLowerCase();
1553
+ const normalizedContent = testFile.content.replace(/\[([^\]]+)\]/g, '$1').toLowerCase();
1554
+ if (normalizedContent.includes(normalizedRoute)) return true;
1555
+ const source = findIndexedFile(`src/app${route}/route.ts`, context);
1556
+ if (source && normalizedContent.includes(path.basename(source.relativePath, source.ext).toLowerCase())) return true;
1557
+ const segments = normalizedRoute.split('/').filter((segment) => segment.length >= 3);
1558
+ return segments.length > 0 && segments.every((segment) => normalizedContent.includes(segment));
1559
+ }
1560
+
1561
+ function extractPathDomainTokens(relativePath) {
1562
+ return splitNormalizedPathSegments(relativePath)
1563
+ .flatMap((segment) => tokenize(segment))
1564
+ .filter((token) => token.length >= 2 && !GENERIC_TASK_TOKENS.has(token));
1565
+ }
1566
+
1567
+ function collectRecipeLineContext(content, index) {
1568
+ const source = String(content || '');
1569
+ const lineStart = source.lastIndexOf('\n', index);
1570
+ const lineEnd = source.indexOf('\n', index);
1571
+ const previousLineStart = lineStart > 0 ? source.lastIndexOf('\n', lineStart - 1) : -1;
1572
+ const start = previousLineStart >= 0 ? previousLineStart + 1 : 0;
1573
+ const end = lineEnd >= 0 ? lineEnd : source.length;
1574
+ return source.slice(start, end);
1575
+ }
1576
+
1577
+ function appendUniqueDiagnostic(diagnostics, diagnostic) {
1578
+ if (!diagnostic || !diagnostic.code) {
1579
+ return diagnostics;
1580
+ }
1581
+ if (diagnostics.some((item) => item.code === diagnostic.code)) {
1582
+ return diagnostics;
1583
+ }
1584
+ diagnostics.push(diagnostic);
1585
+ return diagnostics;
1586
+ }
1587
+
1588
+ function buildTaskRecipeCandidatePool(task, context) {
1589
+ const { paths: pathHints, externalPaths } = extractExplicitPaths(task.text);
1590
+ const standaloneFilenames = extractStandaloneFilenames(task.text);
1591
+ const symbolHints = extractSymbolHints(task.text);
1592
+ const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...externalPaths, ...standaloneFilenames]);
1593
+ const heuristicOptions = {
1594
+ explicitPaths: [...pathHints, ...externalPaths],
1595
+ explicitFilenames: standaloneFilenames
1596
+ };
1597
+ const candidatePaths = unionArrays(
1598
+ findFilesByPathHints(pathHints, context.pathHintResolver),
1599
+ findFilesBySymbols(symbolHints, context.fileIndex, heuristicOptions),
1600
+ findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens, heuristicOptions),
1601
+ findFilesByTaskPathTokens(task.text, context.fileIndex, pathDerivedTokens, heuristicOptions)
1602
+ );
1603
+ const indexedFiles = new Map(context.fileIndex.map((file) => [file.relativePath, file]));
1604
+ return candidatePaths.map((relativePath) => indexedFiles.get(relativePath)).filter(Boolean);
1605
+ }
1606
+
1607
+ function findVerificationRecipe(task, context) {
1608
+ if ((Array.isArray(task.verifyLines) && task.verifyLines.length > 0) || (Array.isArray(task.evidenceLines) && task.evidenceLines.length > 0)) {
1609
+ return { recipe: null, specificityFailure: false, foundStaticSignal: false };
1610
+ }
1611
+
1612
+ const patterns = [
1613
+ /disabled\s*=\s*\{[^}]+\}/i,
1614
+ /<(?:dialog|alertdialog)\b[^>]*\bopen\s*=/i,
1615
+ /abortcontroller|abortsignal\.timeout/i,
1616
+ /router\.push\s*\(/i
1617
+ ];
1618
+ const taskTokens = extractTaskEvidenceTokens(task.text, new Set(), 2);
1619
+ const candidateFiles = buildTaskRecipeCandidatePool(task, context);
1620
+ const candidates = [];
1621
+ let foundStaticSignal = false;
1622
+
1623
+ for (const file of candidateFiles) {
1624
+ if (file.generatedOutput || file.isTestFile || !CODE_EXTENSIONS.has(file.ext)) continue;
1625
+ for (const pattern of patterns) {
1626
+ const match = pattern.exec(file.content);
1627
+ if (!match) {
1628
+ continue;
1629
+ }
1630
+ foundStaticSignal = true;
1631
+ const pathTokens = extractPathDomainTokens(file.relativePath);
1632
+ if (!pathTokens.some((token) => taskTokens.includes(token))) {
1633
+ continue;
1634
+ }
1635
+ const lineContext = collectRecipeLineContext(file.content, match.index);
1636
+ const contextTokens = extractTaskEvidenceTokens(`${match[0]} ${lineContext}`, new Set(), 2);
1637
+ if (!contextTokens.some((token) => taskTokens.includes(token))) {
1638
+ continue;
1639
+ }
1640
+ const line = file.content.slice(0, match.index).split(/\r?\n/).length;
1641
+ const command = context.config.validation && context.config.validation.recipeCommand
1642
+ ? `; run ${String(context.config.validation.recipeCommand).replace('{testFile}', '<test-file>')}`
1643
+ : '';
1644
+ candidates.push(`${file.relativePath}:${line} inspect ${match[0].trim()}${command}`);
1645
+ break;
1646
+ }
1647
+ }
1648
+
1649
+ const uniqueCandidates = Array.from(new Set(candidates));
1650
+ if (uniqueCandidates.length === 1) {
1651
+ return { recipe: uniqueCandidates[0], specificityFailure: false, foundStaticSignal };
1652
+ }
1653
+
1654
+ return {
1655
+ recipe: null,
1656
+ specificityFailure: foundStaticSignal || candidates.length > 0,
1657
+ foundStaticSignal
1658
+ };
1659
+ }
1660
+
1661
+ function isBehavioralTask(taskText) {
1662
+ return /\b(mostrar|deshabilitar|confirmar|notificar|redirigir|imprimir|show|disable|confirm|notify|redirect|print)\b/i.test(String(taskText || ''));
1663
+ }
1664
+
1665
+ function evaluateDeterministicVerification(task, context) {
1666
+ const verifyLines = Array.isArray(task.verifyLines) ? task.verifyLines : [];
1667
+ if (verifyLines.length === 0) {
1668
+ if (!isBehavioralTask(task.text)) {
1669
+ return { applicable: false, passed: false, reasons: [], diagnostics: [], recipe: null };
1670
+ }
1671
+ const recipeResult = findVerificationRecipe(task, context);
1672
+ const diagnostics = [];
1673
+ if (recipeResult.specificityFailure) {
1674
+ diagnostics.push({
1675
+ code: 'REQUIRES_HUMAN_EVIDENCE',
1676
+ severity: 'warning',
1677
+ message: 'behavioral task requires explicit human or test evidence'
1678
+ });
1679
+ diagnostics.push({
1680
+ code: 'NO_SPECIFIC_RECIPE',
1681
+ severity: 'warning',
1682
+ message: 'no task-specific verification recipe could be generated'
1683
+ });
1684
+ } else {
1685
+ diagnostics.push({
1686
+ code: recipeResult.recipe ? 'REQUIRES_HUMAN_EVIDENCE' : 'NO_STATIC_SIGNAL',
1687
+ severity: 'warning',
1688
+ message: recipeResult.recipe ? 'behavioral task requires explicit human or test evidence' : 'no static implementation signal was found for behavioral task'
1689
+ });
1690
+ }
1691
+ return {
1692
+ applicable: false,
1693
+ passed: false,
1694
+ reasons: [],
1695
+ diagnostics,
1696
+ recipe: recipeResult.recipe
1697
+ };
1698
+ }
1699
+ const reasons = [];
1700
+ const diagnostics = [];
1701
+ let generatedTestEvidence = null;
1702
+
1703
+ for (const line of verifyLines) {
1704
+ const fields = parseVerificationFields(line.text);
1705
+ const kind = fields.kind;
1706
+ if (kind === 'contains' || kind === 'property') {
1707
+ const file = findIndexedFile(fields.file, context);
1708
+ if (!file) {
1709
+ reasons.push(`missing referenced file(s): ${fields.file || '<unspecified>'}`);
1710
+ continue;
1711
+ }
1712
+ const content = stripCodeComments(file.content);
1713
+ if (kind === 'contains') {
1714
+ if (!fields.expected || !content.includes(fields.expected)) {
1715
+ reasons.push(`no content match in ${file.relativePath}: expected ${fields.expected || '<unspecified>'}`);
1716
+ }
1717
+ continue;
1718
+ }
1719
+ const keyPattern = new RegExp(`\\b${escapeRegExp(fields.key || '')}\\s*:`);
1720
+ const exactPattern = new RegExp(`\\b${escapeRegExp(fields.key || '')}\\s*:\\s*${escapeRegExp(fields.equals || '')}(?=\\s*[,}\\n])`);
1721
+ if (!exactPattern.test(content)) {
1722
+ reasons.push(keyPattern.test(content)
1723
+ ? `wrong value for ${fields.key} in ${file.relativePath}: expected ${fields.equals}`
1724
+ : `no content match in ${file.relativePath}: expected ${fields.key}: ${fields.equals}`);
1725
+ }
1726
+ continue;
1727
+ }
1728
+ if (kind === 'endpoints') {
1729
+ const routes = String(fields.routes || '').split(',').map((value) => value.trim()).filter(Boolean);
1730
+ const tests = context.fileIndex.filter((file) => !file.generatedOutput && file.isTestFile);
1731
+ const covered = routes.filter((route) => tests.some((file) => testCoversEndpoint(file, route, context)));
1732
+ if (covered.length !== routes.length) {
1733
+ const missing = routes.filter((route) => !covered.includes(route));
1734
+ reasons.push(`partial endpoint coverage ${covered.length}/${routes.length}: missing ${missing.join(', ')}`);
1735
+ }
1736
+ continue;
1737
+ }
1738
+ if (kind === 'behavior') {
1739
+ const source = findIndexedFile(fields.source, context);
1740
+ const testFile = findIndexedFile(fields.test, context);
1741
+ const testContent = testFile ? testFile.content : '';
1742
+ const sourceReference = source && testContent.includes(path.basename(source.relativePath, source.ext));
1743
+ const exercise = fields.trigger && testContent.includes(fields.trigger);
1744
+ const assertion = fields.assertion && testContent.includes(fields.assertion);
1745
+ const namedCase = fields.case && testContent.includes(fields.case);
1746
+ const proof = findFreshTestProof(task, fields, context);
1747
+ if (!source || !testFile || !sourceReference || !exercise || !assertion || !namedCase) {
1748
+ diagnostics.push({ code: 'REQUIRES_HUMAN_EVIDENCE', severity: 'warning', message: 'behavior verification needs a source reference, named test, trigger, and assertion' });
1749
+ continue;
1750
+ }
1751
+ if (proof.stale) {
1752
+ diagnostics.push({ code: 'STALE_TEST_REPORT', severity: 'warning', message: `test result for ${testFile.relativePath} predates the verified source` });
1753
+ continue;
1754
+ }
1755
+ if (!proof.passed) {
1756
+ diagnostics.push({ code: 'REQUIRES_HUMAN_EVIDENCE', severity: 'warning', message: `no fresh passing result for ${testFile.relativePath}` });
1757
+ continue;
1758
+ }
1759
+ generatedTestEvidence = proof.generatedEvidence || generatedTestEvidence;
1760
+ continue;
1761
+ }
1762
+ reasons.push(`unsupported verification kind: ${kind || '<unspecified>'}`);
1763
+ }
1764
+
1765
+ const passed = reasons.length === 0 && diagnostics.length === 0;
1766
+ return {
1767
+ applicable: true,
1768
+ passed,
1769
+ reasons,
1770
+ diagnostics,
1771
+ generatedTestEvidence,
1772
+ recipe: null
1773
+ };
1774
+ }
1775
+
1776
+ function buildValidationContext(projectRoot, config, plugins, options = {}) {
1277
1777
  const files = walkFiles(projectRoot);
1278
1778
  const fileIndex = readFileIndex(projectRoot, files, config);
1279
1779
  const testFrameworks = detectTestFrameworks(projectRoot, files);
@@ -1286,7 +1786,9 @@ function buildValidationContext(projectRoot, config, plugins) {
1286
1786
  files,
1287
1787
  fileIndex,
1288
1788
  pathHintResolver,
1289
- testFrameworks
1789
+ testFrameworks,
1790
+ testReportRecords: readTestReportRecords(projectRoot, config.validation),
1791
+ strictValidation: options.strictValidation === true
1290
1792
  };
1291
1793
  }
1292
1794
 
@@ -1298,6 +1800,15 @@ function diagnosticCodeForReason(reason) {
1298
1800
  if (normalized.includes('missing test evidence')) {
1299
1801
  return 'NO_TEST';
1300
1802
  }
1803
+ if (normalized.includes('wrong value')) {
1804
+ return 'WRONG_VALUE';
1805
+ }
1806
+ if (normalized.includes('partial endpoint coverage')) {
1807
+ return 'PARTIAL';
1808
+ }
1809
+ if (normalized.includes('no content match')) {
1810
+ return 'NOT_IMPLEMENTED';
1811
+ }
1301
1812
  if (
1302
1813
  normalized.includes('no code, test, or artifact evidence found') ||
1303
1814
  normalized.includes('implementation task requires evidence line') ||
@@ -1327,6 +1838,10 @@ function buildDiagnostics(reasons, options = {}) {
1327
1838
  message: 'historical validation warning conflicts with fresh repository evidence'
1328
1839
  });
1329
1840
  }
1841
+ for (const diagnostic of options.extra || []) {
1842
+ if (!diagnostic || !diagnostic.code || diagnostics.some((item) => item.code === diagnostic.code)) continue;
1843
+ diagnostics.push(diagnostic);
1844
+ }
1330
1845
  return diagnostics;
1331
1846
  }
1332
1847
 
@@ -1347,19 +1862,26 @@ function validateTask(task, context, config, plugins) {
1347
1862
  const purePathHints = pathHints.filter((p) => !lineReferenceHints.has(p));
1348
1863
  const standaloneFilenames = extractStandaloneFilenames(task.text);
1349
1864
  const symbolHints = extractSymbolHints(task.text);
1865
+ const heuristicOptions = {
1866
+ explicitPaths: [...pathHints, ...externalPaths],
1867
+ explicitFilenames: standaloneFilenames
1868
+ };
1869
+ const strictValidation = context.strictValidation === true;
1350
1870
  const authoritativeEvidence = evaluateAuthoritativeEvidence(task, context.pathHintResolver);
1871
+ const deterministicVerification = evaluateDeterministicVerification(task, context);
1872
+ const hasExplicitPendingItems = Array.isArray(task.explicitPendingItems) && task.explicitPendingItems.length > 0;
1351
1873
 
1352
1874
  const filesFromPaths = findFilesByPathHints(pathHints, context.pathHintResolver);
1353
1875
  const filesFromPurePathHints = findFilesByPathHints(purePathHints, context.pathHintResolver);
1354
- const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
1876
+ const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex, heuristicOptions);
1355
1877
  // Combine path hints AND standalone filenames for token exclusion so that tokens
1356
1878
  // derived from any referenced filename (e.g. "roadmap-skill" from
1357
1879
  // "roadmap-skill.config.json") are excluded from code evidence scoring.
1358
1880
  const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...externalPaths, ...standaloneFilenames]);
1359
- const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens);
1360
- const filesFromWeakPathTokens = findFilesByTaskPathTokens(task.text, context.fileIndex, pathDerivedTokens);
1361
- const weakPathContentTokens = findWeakPathContentSpecificTokens(task.text, context.fileIndex, filesFromWeakPathTokens, pathDerivedTokens);
1362
- const filesFromTests = findTestEvidence(task.text, context.fileIndex, [...pathHints, ...standaloneFilenames]);
1881
+ const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens, heuristicOptions);
1882
+ const filesFromWeakPathTokens = findFilesByTaskPathTokens(task.text, context.fileIndex, pathDerivedTokens, heuristicOptions);
1883
+ const weakPathContentTokens = findWeakPathContentSpecificTokens(task.text, context.fileIndex, filesFromWeakPathTokens, pathDerivedTokens, heuristicOptions);
1884
+ const filesFromTests = findTestEvidence(task.text, context.fileIndex, [...pathHints, ...standaloneFilenames], heuristicOptions);
1363
1885
  const { files: filesFromArtifacts, heuristicArtifacts } = findArtifactEvidence(task.text, context.fileIndex);
1364
1886
 
1365
1887
  const structuralCheck = checkNamespaceStructuralEvidence(task.id, task.text, context.fileIndex);
@@ -1424,7 +1946,8 @@ function validateTask(task, context, config, plugins) {
1424
1946
  context.testFrameworks.length > 0 &&
1425
1947
  isCodeTask(task.text) &&
1426
1948
  !isDocTask(task.text) &&
1427
- !isHttpExpectationTask(task.text);
1949
+ !isHttpExpectationTask(task.text) &&
1950
+ !(task.verifyLines || []).some((line) => ['contains', 'property', 'endpoints', 'behavior'].includes(parseVerificationFields(line.text).kind));
1428
1951
  const configuredRules = Array.isArray(config.validators) ? config.validators : [];
1429
1952
  const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
1430
1953
  let overrideResult = null;
@@ -1458,6 +1981,18 @@ function validateTask(task, context, config, plugins) {
1458
1981
 
1459
1982
  let uniqueReasons = Array.from(new Set(reasons));
1460
1983
 
1984
+ if (deterministicVerification.passed) {
1985
+ uniqueReasons = uniqueReasons.filter((reason) => (
1986
+ reason !== 'no code, test, or artifact evidence found' &&
1987
+ reason !== 'missing test evidence' &&
1988
+ !reason.startsWith('weak path-')
1989
+ ));
1990
+ }
1991
+
1992
+ if (deterministicVerification.applicable && deterministicVerification.reasons.length > 0) {
1993
+ uniqueReasons = Array.from(new Set([...uniqueReasons, ...deterministicVerification.reasons]));
1994
+ }
1995
+
1461
1996
  if (overrideResult) {
1462
1997
  uniqueReasons = Array.isArray(overrideResult.reasons) ? Array.from(new Set(overrideResult.reasons)) : [];
1463
1998
  }
@@ -1516,7 +2051,17 @@ function validateTask(task, context, config, plugins) {
1516
2051
  const hasFreshRepositoryEvidence = hasStrongEvidence || hasWeakEvidence;
1517
2052
  let staleEvidenceDetected = false;
1518
2053
  let staleEvidenceResolved = false;
1519
- let passed = authoritativeEvidence.passed || hasArtifactTaskPass || hasTrustedRuleEvidencePass || meetsStrongThreshold;
2054
+ // Heuristic proximity remains useful to locate candidates, but is never sufficient to
2055
+ // complete an unchecked task. Completion requires human Evidence, a trusted rule, or a
2056
+ // typed deterministic verifier.
2057
+ let passed = strictValidation
2058
+ ? (authoritativeEvidence.passed || deterministicVerification.passed)
2059
+ : (authoritativeEvidence.passed || hasTrustedRuleEvidencePass || deterministicVerification.passed);
2060
+
2061
+ if (!task.checked && !authoritativeEvidence.passed && !hasTrustedRuleEvidencePass && !deterministicVerification.passed && hasHighConfidenceImplementationEvidence) {
2062
+ uniqueReasons.push('implementation task requires deterministic Verify metadata or explicit Evidence to be marked complete');
2063
+ uniqueReasons = Array.from(new Set(uniqueReasons));
2064
+ }
1520
2065
 
1521
2066
  if (task.warningText && !task.checked && hasFreshRepositoryEvidence && !authoritativeEvidence.passed) {
1522
2067
  staleEvidenceDetected = true;
@@ -1531,7 +2076,7 @@ function validateTask(task, context, config, plugins) {
1531
2076
 
1532
2077
  // Historical warnings are only cleared by independent, high-confidence repository evidence.
1533
2078
  if (task.warningText && !task.checked && passed && !authoritativeEvidence.passed) {
1534
- if (hasHighConfidenceImplementationEvidence && negativeSignalMatches.length === 0 && uniqueReasons.length === 0) {
2079
+ if ((deterministicVerification.passed || hasHighConfidenceImplementationEvidence) && negativeSignalMatches.length === 0 && uniqueReasons.length === 0) {
1535
2080
  staleEvidenceResolved = true;
1536
2081
  } else {
1537
2082
  passed = false;
@@ -1544,6 +2089,16 @@ function validateTask(task, context, config, plugins) {
1544
2089
  passed = false;
1545
2090
  }
1546
2091
 
2092
+ const extraDiagnostics = [...deterministicVerification.diagnostics];
2093
+ if (hasExplicitPendingItems) {
2094
+ passed = false;
2095
+ extraDiagnostics.push({
2096
+ code: 'HAS_EXPLICIT_PENDING',
2097
+ severity: 'warning',
2098
+ message: `task declares pending item(s): ${task.explicitPendingItems.map((item) => item.text || 'pending').join(', ')}`
2099
+ });
2100
+ }
2101
+
1547
2102
  // Unchecked implementation tasks need explicit evidence or high-confidence implementation
1548
2103
  // evidence. Weak token overlap, direct file references, or code-only matches are not enough.
1549
2104
  if (
@@ -1552,6 +2107,7 @@ function validateTask(task, context, config, plugins) {
1552
2107
  isImplementationTask(task.text) &&
1553
2108
  !authoritativeEvidence.passed &&
1554
2109
  !hasTrustedRuleEvidencePass &&
2110
+ !deterministicVerification.passed &&
1555
2111
  !hasArtifactTaskPass &&
1556
2112
  !hasHighConfidenceImplementationEvidence
1557
2113
  ) {
@@ -1571,6 +2127,7 @@ function validateTask(task, context, config, plugins) {
1571
2127
  const shouldPreserveCheckedTask =
1572
2128
  task.checked &&
1573
2129
  !passed &&
2130
+ !strictValidation &&
1574
2131
  !authoritativeEvidence.active &&
1575
2132
  symbolHints.length === 0 &&
1576
2133
  negativeSignalMatches.length === 0 &&
@@ -1588,13 +2145,15 @@ function validateTask(task, context, config, plugins) {
1588
2145
  // Used by auditValidation to flag implementation tasks that pass solely via documentation.
1589
2146
  const evidenceIsDocOnly = !evidence.code && !evidence.test && evidence.artifact && !isDocTask(task.text);
1590
2147
 
1591
- const finalPassed = overrideResult ? overrideResult.passed !== false : (passed && uniqueReasons.length === 0);
2148
+ const finalPassed = (!strictValidation && overrideResult)
2149
+ ? (overrideResult.passed !== false && !hasExplicitPendingItems)
2150
+ : (passed && uniqueReasons.length === 0 && !hasExplicitPendingItems);
1592
2151
  return {
1593
2152
  taskId: task.id,
1594
2153
  passed: finalPassed,
1595
2154
  confidence,
1596
2155
  reasons: uniqueReasons,
1597
- diagnostics: buildDiagnostics(uniqueReasons, { staleEvidence: staleEvidenceDetected }),
2156
+ diagnostics: buildDiagnostics(uniqueReasons, { staleEvidence: staleEvidenceDetected, extra: extraDiagnostics }),
1598
2157
  evidence,
1599
2158
  evidenceIsDocOnly,
1600
2159
  requiresTest,
@@ -1602,7 +2161,9 @@ function validateTask(task, context, config, plugins) {
1602
2161
  attempted,
1603
2162
  preservedCheckedState,
1604
2163
  staleEvidenceResolved,
1605
- discoveredEvidence: staleEvidenceResolved ? buildDiscoveredEvidenceLine(evidence) : null
2164
+ discoveredEvidence: staleEvidenceResolved ? buildDiscoveredEvidenceLine(evidence) : null,
2165
+ verificationRecipe: deterministicVerification.recipe,
2166
+ generatedTestEvidence: deterministicVerification.generatedTestEvidence || null
1606
2167
  };
1607
2168
  }
1608
2169
 
@@ -1635,6 +2196,33 @@ function validateTasks(tasks, context, config, plugins) {
1635
2196
  }
1636
2197
  }
1637
2198
 
2199
+ const recipeOwners = new Map();
2200
+ for (const [taskId, taskResult] of Object.entries(result)) {
2201
+ if (!taskResult.verificationRecipe) {
2202
+ continue;
2203
+ }
2204
+ const owners = recipeOwners.get(taskResult.verificationRecipe) || [];
2205
+ owners.push(taskId);
2206
+ recipeOwners.set(taskResult.verificationRecipe, owners);
2207
+ }
2208
+
2209
+ for (const [recipe, owners] of recipeOwners.entries()) {
2210
+ if (owners.length < 2) {
2211
+ continue;
2212
+ }
2213
+ for (const taskId of owners) {
2214
+ if (!result[taskId] || result[taskId].verificationRecipe !== recipe) {
2215
+ continue;
2216
+ }
2217
+ result[taskId].verificationRecipe = null;
2218
+ appendUniqueDiagnostic(result[taskId].diagnostics, {
2219
+ code: 'NO_SPECIFIC_RECIPE',
2220
+ severity: 'warning',
2221
+ message: 'generated verification recipe was not unique to a single task'
2222
+ });
2223
+ }
2224
+ }
2225
+
1638
2226
  return result;
1639
2227
  }
1640
2228