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.
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/README.md +100 -314
- package/bin/cli.js +49 -15
- package/package.json +2 -2
- package/skills/roadmap-status/SKILL.md +7 -6
- package/skills/roadmap-sync/SKILL.md +1 -1
- package/skills/roadmap-update/SKILL.md +4 -4
- package/skills.json +13 -13
- package/src/config.js +7 -2
- package/src/host.js +96 -27
- package/src/io.js +2 -0
- package/src/parser/index.js +50 -0
- package/src/slash.js +126 -44
- package/src/sync/index.js +48 -4
- package/src/validator/index.js +614 -26
package/src/validator/index.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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 >=
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|