roadmapsmith 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/package.json +1 -1
- package/src/parser/index.js +4 -1
- package/src/validator/index.js +221 -22
package/README.md
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/PapiScholz/roadmapsmith/main/assets/roadmapsmith-logo.png" alt="RoadmapSmith logo" width="180">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">RoadmapSmith</h1>
|
|
2
6
|
|
|
3
7
|
Production-grade roadmap generator and sync tool for agent-driven projects.
|
|
4
8
|
|
|
@@ -120,6 +124,12 @@ Create `roadmap-skill.config.json`:
|
|
|
120
124
|
"type": "file-exists",
|
|
121
125
|
"when": "migration",
|
|
122
126
|
"path": "db/migrations"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"type": "grant-evidence",
|
|
130
|
+
"whenId": "^p0-electron-builder-windows$",
|
|
131
|
+
"evidence": ["test"],
|
|
132
|
+
"testFiles": ["test/electron-builder.test.js"]
|
|
123
133
|
}
|
|
124
134
|
],
|
|
125
135
|
"customSections": [
|
|
@@ -147,6 +157,20 @@ Create `roadmap-skill.config.json`:
|
|
|
147
157
|
}
|
|
148
158
|
```
|
|
149
159
|
|
|
160
|
+
Task markers can include `rs:no-test` to disable the test-evidence requirement for one task:
|
|
161
|
+
|
|
162
|
+
```markdown
|
|
163
|
+
- [ ] Add Windows autostart script <!-- rs:task=p0-windows-autostart rs:no-test -->
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Validator rules are backward compatible:
|
|
167
|
+
|
|
168
|
+
- `when` matches task text.
|
|
169
|
+
- `whenId` matches the stable `rs:task` ID.
|
|
170
|
+
- `grant-evidence` can grant `code`, `test`, or `artifact` evidence without `overrideResult`.
|
|
171
|
+
- `overrideResult: true` is only needed when a rule should replace automatic failures.
|
|
172
|
+
- Tests that read a referenced file with `fs.readFileSync`, `fs.readFile`, `readFileSync`, or `readFile` can count as test evidence for tasks that explicitly mention that file.
|
|
173
|
+
|
|
150
174
|
## Plugin API
|
|
151
175
|
|
|
152
176
|
Plugin module path(s) are loaded from `config.plugins` in deterministic order.
|
package/package.json
CHANGED
package/src/parser/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { slugify } = require('../utils');
|
|
4
4
|
|
|
5
|
-
const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)
|
|
5
|
+
const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)([^>]*)-->)?\s*$/;
|
|
6
6
|
const WARNING_RE = /^\s*-\s+⚠️ attempted but validation failed:\s*(.+?)\s*$/;
|
|
7
7
|
const HEADING_RE = /^#{2,3}\s+(.*)$/;
|
|
8
8
|
|
|
@@ -30,6 +30,8 @@ function parseRoadmap(content) {
|
|
|
30
30
|
const checked = taskMatch[2].toLowerCase() === 'x';
|
|
31
31
|
const text = taskMatch[3].trim();
|
|
32
32
|
const markerId = taskMatch[4] || null;
|
|
33
|
+
const markerFlags = taskMatch[5] || '';
|
|
34
|
+
const noTest = /\brs:no-test\b/i.test(markerFlags);
|
|
33
35
|
|
|
34
36
|
let warningLineIndex = null;
|
|
35
37
|
let warningText = null;
|
|
@@ -51,6 +53,7 @@ function parseRoadmap(content) {
|
|
|
51
53
|
warningLineIndex,
|
|
52
54
|
warningText,
|
|
53
55
|
markerId,
|
|
56
|
+
noTest,
|
|
54
57
|
indent,
|
|
55
58
|
section
|
|
56
59
|
});
|
package/src/validator/index.js
CHANGED
|
@@ -11,6 +11,8 @@ const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
|
|
|
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
|
+
const TRANSLATION_DIR_SEGMENTS = ['locale', 'locales', 'i18n', 'translations'];
|
|
15
|
+
const DEFAULT_EXCLUDED_PATH_PREFIXES = ['.claude/', '.agent/', 'roadmap-skill/'];
|
|
14
16
|
|
|
15
17
|
// "docs" omitted from DOC_HINTS — it is a path prefix in scan tasks, not a doc-authoring keyword.
|
|
16
18
|
const DOC_HINTS = ['readme', 'changelog', 'documentation', 'spec', 'diagram', 'runbook'];
|
|
@@ -80,11 +82,62 @@ function isFixturePath(relativePath) {
|
|
|
80
82
|
return /(?:^|[/\\])fixtures[/\\]/.test(relativePath);
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
function
|
|
85
|
+
function normalizePathForMatch(rawPath) {
|
|
86
|
+
return String(rawPath || '').replace(/\\/g, '/').toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function shouldExcludeByDefaultPath(relativePath, config) {
|
|
90
|
+
const normalized = normalizePathForMatch(relativePath);
|
|
91
|
+
if (DEFAULT_EXCLUDED_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const configuredSkillsDir = config && typeof config.skillsDir === 'string'
|
|
96
|
+
? normalizePathForMatch(config.skillsDir).replace(/^\.?\//, '')
|
|
97
|
+
: '';
|
|
98
|
+
if (configuredSkillsDir && (normalized === configuredSkillsDir || normalized.startsWith(configuredSkillsDir + '/'))) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isTranslationPath(relativePath) {
|
|
105
|
+
const normalized = normalizePathForMatch(relativePath);
|
|
106
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
107
|
+
return segments.some((segment) => TRANSLATION_DIR_SEGMENTS.includes(segment));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function looksLikeTranslationJson(content) {
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(content);
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const values = Object.values(parsed);
|
|
123
|
+
if (values.length === 0) return false;
|
|
124
|
+
const stringValues = values.filter((value) => typeof value === 'string').length;
|
|
125
|
+
return stringValues / values.length >= 0.8;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isMostlyUiStrings(content) {
|
|
129
|
+
const lines = String(content).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
130
|
+
if (lines.length < 5) return false;
|
|
131
|
+
const stringLikeLines = lines.filter((line) => /^['"`][^'"`]{1,200}['"`],?$/.test(line) || /^[A-Za-z0-9_.-]+\s*:\s*['"`][^'"`]{1,200}['"`],?$/.test(line)).length;
|
|
132
|
+
return stringLikeLines / lines.length > 0.8;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readFileIndex(projectRoot, files, config) {
|
|
84
136
|
const index = [];
|
|
85
137
|
for (const relativePath of files) {
|
|
86
138
|
if (SELF_REFERENTIAL_FILES.has(relativePath)) continue;
|
|
87
139
|
if (isFixturePath(relativePath)) continue;
|
|
140
|
+
if (shouldExcludeByDefaultPath(relativePath, config)) continue;
|
|
88
141
|
|
|
89
142
|
const absolutePath = path.resolve(projectRoot, relativePath);
|
|
90
143
|
const ext = path.extname(relativePath).toLowerCase();
|
|
@@ -99,6 +152,10 @@ function readFileIndex(projectRoot, files) {
|
|
|
99
152
|
continue;
|
|
100
153
|
}
|
|
101
154
|
|
|
155
|
+
if (isTranslationPath(relativePath)) continue;
|
|
156
|
+
if (ext === '.json' && looksLikeTranslationJson(content)) continue;
|
|
157
|
+
if (isMostlyUiStrings(content)) continue;
|
|
158
|
+
|
|
102
159
|
index.push({
|
|
103
160
|
relativePath,
|
|
104
161
|
absolutePath,
|
|
@@ -256,6 +313,46 @@ function findFilesBySymbols(symbolHints, fileIndex) {
|
|
|
256
313
|
return Array.from(matches).sort((left, right) => left.localeCompare(right));
|
|
257
314
|
}
|
|
258
315
|
|
|
316
|
+
function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new Set()) {
|
|
317
|
+
const tokens = tokenize(taskText)
|
|
318
|
+
.filter((token) => token.length >= 6 && !GENERIC_TASK_TOKENS.has(token) && !pathDerivedTokens.has(token))
|
|
319
|
+
.slice(0, 10);
|
|
320
|
+
if (tokens.length === 0) return [];
|
|
321
|
+
|
|
322
|
+
const matches = new Set();
|
|
323
|
+
for (const file of fileIndex) {
|
|
324
|
+
const pathSegments = normalizePathForMatch(file.relativePath).split('/').filter(Boolean);
|
|
325
|
+
for (const token of tokens) {
|
|
326
|
+
if (pathSegments.some((segment) => segment === token || segment.includes(token))) {
|
|
327
|
+
matches.add(file.relativePath);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (matches.size >= 20) break;
|
|
332
|
+
}
|
|
333
|
+
return Array.from(matches).sort((left, right) => left.localeCompare(right));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function mergeRuleEvidence(baseEvidence, ruleEvidence) {
|
|
337
|
+
if (!ruleEvidence || typeof ruleEvidence !== 'object') return baseEvidence;
|
|
338
|
+
const merged = { ...baseEvidence };
|
|
339
|
+
|
|
340
|
+
for (const [key, value] of Object.entries(ruleEvidence)) {
|
|
341
|
+
if (Array.isArray(value)) {
|
|
342
|
+
const existing = Array.isArray(merged[key]) ? merged[key] : [];
|
|
343
|
+
merged[key] = Array.from(new Set([...existing, ...value]));
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (typeof value === 'boolean') {
|
|
347
|
+
merged[key] = Boolean(merged[key]) || value;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
merged[key] = value;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return merged;
|
|
354
|
+
}
|
|
355
|
+
|
|
259
356
|
// Tokens extracted from a referenced file path (e.g. "roadmap-skill" from
|
|
260
357
|
// "roadmap-skill.config.json") must not be reused as code evidence signals.
|
|
261
358
|
// Those tokens appear in any file that mentions the same path — creating circular
|
|
@@ -314,12 +411,45 @@ function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
|
|
|
314
411
|
return matches.slice(0, 20);
|
|
315
412
|
}
|
|
316
413
|
|
|
317
|
-
function
|
|
414
|
+
function normalizeReferencedPath(rawPath) {
|
|
415
|
+
return String(rawPath || '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function referencedPathMatches(readRef, referencedPath) {
|
|
419
|
+
const normalizedRef = normalizeReferencedPath(readRef);
|
|
420
|
+
const normalizedHint = normalizeReferencedPath(referencedPath);
|
|
421
|
+
if (!normalizedRef || !normalizedHint) return false;
|
|
422
|
+
if (normalizedRef === normalizedHint || normalizedRef.endsWith('/' + normalizedHint)) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
return path.basename(normalizedRef) === normalizedHint || normalizedRef === path.basename(normalizedHint);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function extractTestReadReferences(content) {
|
|
429
|
+
const refs = [];
|
|
430
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
431
|
+
for (const line of lines) {
|
|
432
|
+
if (!/\b(?:fs\.)?readFile(?:Sync)?\s*\(/.test(line)) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const stringLiterals = line.match(/['"`]([^'"`]+)['"`]/g) || [];
|
|
436
|
+
for (const literal of stringLiterals) {
|
|
437
|
+
const value = literal.slice(1, -1);
|
|
438
|
+
if (hasKnownFileExtension(value) || value.includes('/') || value.includes('\\')) {
|
|
439
|
+
refs.push(value);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return refs;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function findTestEvidence(taskText, fileIndex, referencedPaths = []) {
|
|
318
447
|
const tokens = tokenize(taskText)
|
|
319
448
|
.filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/'))
|
|
320
449
|
.slice(0, 8);
|
|
321
450
|
|
|
322
|
-
|
|
451
|
+
const pathRefs = Array.from(new Set(referencedPaths)).filter(Boolean);
|
|
452
|
+
if (tokens.length === 0 && pathRefs.length === 0) return [];
|
|
323
453
|
|
|
324
454
|
// Only tokens of length >= 4 are used for import-reference matching.
|
|
325
455
|
// Very short tokens (e.g. "app", "web") are too generic: they appear as substrings in
|
|
@@ -355,6 +485,14 @@ function findTestEvidence(taskText, fileIndex) {
|
|
|
355
485
|
const lowered = file.content.toLowerCase();
|
|
356
486
|
if (lowered.includes(tokens[0])) {
|
|
357
487
|
matches.push(file.relativePath);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (pathRefs.length > 0) {
|
|
493
|
+
const readRefs = extractTestReadReferences(file.content);
|
|
494
|
+
if (readRefs.some((readRef) => pathRefs.some((pathRef) => referencedPathMatches(readRef, pathRef)))) {
|
|
495
|
+
matches.push(file.relativePath);
|
|
358
496
|
}
|
|
359
497
|
}
|
|
360
498
|
}
|
|
@@ -486,25 +624,33 @@ function checkNamespaceStructuralEvidence(taskId, taskText, fileIndex) {
|
|
|
486
624
|
|
|
487
625
|
function evaluateRule(rule, task, context) {
|
|
488
626
|
if (!rule) {
|
|
489
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
627
|
+
return { passed: true, reasons: [], evidence: {}, overrideResult: false };
|
|
490
628
|
}
|
|
491
629
|
|
|
492
630
|
if (rule.when) {
|
|
493
631
|
const regexp = new RegExp(rule.when, 'i');
|
|
494
632
|
if (!regexp.test(task.text)) {
|
|
495
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
633
|
+
return { passed: true, reasons: [], evidence: {}, overrideResult: false };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (rule.whenId) {
|
|
638
|
+
const regexp = new RegExp(rule.whenId, 'i');
|
|
639
|
+
if (!regexp.test(task.id)) {
|
|
640
|
+
return { passed: true, reasons: [], evidence: {}, overrideResult: false };
|
|
496
641
|
}
|
|
497
642
|
}
|
|
498
643
|
|
|
499
644
|
if (typeof rule.check === 'function') {
|
|
500
645
|
const custom = rule.check(task, context);
|
|
501
646
|
if (!custom) {
|
|
502
|
-
return { passed: true, reasons: [], evidence: {} };
|
|
647
|
+
return { passed: true, reasons: [], evidence: {}, overrideResult: false };
|
|
503
648
|
}
|
|
504
649
|
return {
|
|
505
650
|
passed: custom.passed !== false,
|
|
506
651
|
reasons: Array.isArray(custom.reasons) ? custom.reasons : [],
|
|
507
|
-
evidence: custom.evidence || {}
|
|
652
|
+
evidence: custom.evidence || {},
|
|
653
|
+
overrideResult: rule.overrideResult === true || custom.overrideResult === true
|
|
508
654
|
};
|
|
509
655
|
}
|
|
510
656
|
|
|
@@ -543,16 +689,42 @@ function evaluateRule(rule, task, context) {
|
|
|
543
689
|
reasons.push(rule.message || 'test framework not detected');
|
|
544
690
|
}
|
|
545
691
|
|
|
692
|
+
if (rule.type === 'grant-evidence') {
|
|
693
|
+
const evidenceTargets = Array.isArray(rule.evidence) ? rule.evidence : [rule.evidence].filter(Boolean);
|
|
694
|
+
for (const key of evidenceTargets) {
|
|
695
|
+
evidence[key] = true;
|
|
696
|
+
}
|
|
697
|
+
if (Array.isArray(rule.files) && rule.files.length > 0) {
|
|
698
|
+
evidence.files = rule.files;
|
|
699
|
+
}
|
|
700
|
+
if (Array.isArray(rule.symbols) && rule.symbols.length > 0) {
|
|
701
|
+
evidence.symbols = rule.symbols;
|
|
702
|
+
}
|
|
703
|
+
if (Array.isArray(rule.codeFiles) && rule.codeFiles.length > 0) {
|
|
704
|
+
evidence.codeFiles = rule.codeFiles;
|
|
705
|
+
evidence.code = true;
|
|
706
|
+
}
|
|
707
|
+
if (Array.isArray(rule.testFiles) && rule.testFiles.length > 0) {
|
|
708
|
+
evidence.testFiles = rule.testFiles;
|
|
709
|
+
evidence.test = true;
|
|
710
|
+
}
|
|
711
|
+
if (Array.isArray(rule.artifactFiles) && rule.artifactFiles.length > 0) {
|
|
712
|
+
evidence.artifactFiles = rule.artifactFiles;
|
|
713
|
+
evidence.artifact = true;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
546
717
|
return {
|
|
547
718
|
passed: reasons.length === 0,
|
|
548
719
|
reasons,
|
|
549
|
-
evidence
|
|
720
|
+
evidence,
|
|
721
|
+
overrideResult: rule.overrideResult === true
|
|
550
722
|
};
|
|
551
723
|
}
|
|
552
724
|
|
|
553
725
|
function buildValidationContext(projectRoot, config, plugins) {
|
|
554
726
|
const files = walkFiles(projectRoot);
|
|
555
|
-
const fileIndex = readFileIndex(projectRoot, files);
|
|
727
|
+
const fileIndex = readFileIndex(projectRoot, files, config);
|
|
556
728
|
const testFrameworks = detectTestFrameworks(projectRoot, files);
|
|
557
729
|
|
|
558
730
|
return {
|
|
@@ -577,7 +749,8 @@ function validateTask(task, context, config, plugins) {
|
|
|
577
749
|
// "roadmap-skill.config.json") are excluded from code evidence scoring.
|
|
578
750
|
const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...standaloneFilenames]);
|
|
579
751
|
const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens);
|
|
580
|
-
const
|
|
752
|
+
const filesFromWeakPathTokens = findFilesByTaskPathTokens(task.text, context.fileIndex, pathDerivedTokens);
|
|
753
|
+
const filesFromTests = findTestEvidence(task.text, context.fileIndex, [...pathHints, ...standaloneFilenames]);
|
|
581
754
|
const { files: filesFromArtifacts, heuristicArtifacts } = findArtifactEvidence(task.text, context.fileIndex);
|
|
582
755
|
|
|
583
756
|
const structuralCheck = checkNamespaceStructuralEvidence(task.id, task.text, context.fileIndex);
|
|
@@ -589,6 +762,7 @@ function validateTask(task, context, config, plugins) {
|
|
|
589
762
|
files: filesFromPaths,
|
|
590
763
|
symbols: filesFromSymbols,
|
|
591
764
|
codeFiles: filesFromCode,
|
|
765
|
+
weakPathFiles: filesFromWeakPathTokens,
|
|
592
766
|
testFiles: filesFromTests,
|
|
593
767
|
artifactFiles: filesFromArtifacts,
|
|
594
768
|
heuristicArtifacts,
|
|
@@ -611,31 +785,56 @@ function validateTask(task, context, config, plugins) {
|
|
|
611
785
|
}
|
|
612
786
|
|
|
613
787
|
const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
|
|
614
|
-
|
|
788
|
+
const hasWeakEvidence = filesFromWeakPathTokens.length > 0;
|
|
789
|
+
if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable) {
|
|
615
790
|
reasons.push('no code, test, or artifact evidence found');
|
|
616
|
-
} else if (!hasEvidence && structuralCheck.applicable && structuralCheck.passed) {
|
|
791
|
+
} else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed) {
|
|
617
792
|
reasons.push('no code, test, or artifact evidence found');
|
|
618
793
|
}
|
|
619
794
|
|
|
620
|
-
const requiresTest = context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
|
|
621
|
-
if (requiresTest && !evidence.test) {
|
|
622
|
-
reasons.push('missing test evidence');
|
|
623
|
-
}
|
|
624
|
-
|
|
795
|
+
const requiresTest = !task.noTest && context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
|
|
625
796
|
const configuredRules = Array.isArray(config.validators) ? config.validators : [];
|
|
626
797
|
const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
|
|
798
|
+
let overrideResult = null;
|
|
627
799
|
for (const rule of [...configuredRules, ...pluginRules]) {
|
|
628
800
|
const ruleResult = evaluateRule(rule, task, context);
|
|
801
|
+
if (ruleResult.evidence && Object.keys(ruleResult.evidence).length > 0) {
|
|
802
|
+
Object.assign(evidence, mergeRuleEvidence(evidence, ruleResult.evidence));
|
|
803
|
+
}
|
|
629
804
|
if (!ruleResult.passed) {
|
|
630
805
|
reasons.push(...ruleResult.reasons);
|
|
631
806
|
}
|
|
807
|
+
if (ruleResult.overrideResult) {
|
|
808
|
+
overrideResult = ruleResult;
|
|
809
|
+
}
|
|
632
810
|
}
|
|
633
811
|
|
|
634
|
-
const
|
|
635
|
-
|
|
812
|
+
const hasStrongEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
|
|
813
|
+
if (hasStrongEvidence) {
|
|
814
|
+
const noEvidenceReason = 'no code, test, or artifact evidence found';
|
|
815
|
+
const idx = reasons.indexOf(noEvidenceReason);
|
|
816
|
+
if (idx >= 0) {
|
|
817
|
+
reasons.splice(idx, 1);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (requiresTest && !evidence.test) {
|
|
822
|
+
reasons.push('missing test evidence');
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let uniqueReasons = Array.from(new Set(reasons));
|
|
826
|
+
|
|
827
|
+
if (overrideResult) {
|
|
828
|
+
uniqueReasons = Array.isArray(overrideResult.reasons) ? Array.from(new Set(overrideResult.reasons)) : [];
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0;
|
|
636
832
|
|
|
637
833
|
const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
|
|
638
|
-
|
|
834
|
+
let confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
|
|
835
|
+
if (confidence === 'low' && hasWeakEvidence) {
|
|
836
|
+
confidence = 'low';
|
|
837
|
+
}
|
|
639
838
|
|
|
640
839
|
// True when the only passing evidence is artifact/doc files and the task is not a doc task.
|
|
641
840
|
// Used by auditValidation to flag implementation tasks that pass solely via documentation.
|
|
@@ -643,13 +842,13 @@ function validateTask(task, context, config, plugins) {
|
|
|
643
842
|
|
|
644
843
|
return {
|
|
645
844
|
taskId: task.id,
|
|
646
|
-
passed: uniqueReasons.length === 0,
|
|
845
|
+
passed: overrideResult ? overrideResult.passed !== false : uniqueReasons.length === 0,
|
|
647
846
|
confidence,
|
|
648
847
|
reasons: uniqueReasons,
|
|
649
848
|
evidence,
|
|
650
849
|
evidenceIsDocOnly,
|
|
651
850
|
requiresTest,
|
|
652
|
-
hasEvidence,
|
|
851
|
+
hasEvidence: hasStrongEvidence || hasWeakEvidence,
|
|
653
852
|
attempted
|
|
654
853
|
};
|
|
655
854
|
}
|