roadmapsmith 0.9.6 → 0.9.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadmapsmith",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "Evidence-backed ROADMAP.md generator and sync tool for AI coding agents.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -2,12 +2,89 @@
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-]+)([^>]*)-->)?\s*$/;
6
- const WARNING_RE = /^\s*-\s+⚠️ attempted but validation failed:\s*(.+?)\s*$/;
7
- const HEADING_RE = /^#{2,3}\s+(.*)$/;
8
-
9
5
  const MANAGED_START = '<!-- rs:managed:start -->';
10
6
  const MANAGED_END = '<!-- rs:managed:end -->';
7
+ const WARNING_PREFIX = '⚠️ attempted but validation failed:';
8
+
9
+ function getIndentWidth(text) {
10
+ return String(text || '').replace(/\t/g, ' ').length;
11
+ }
12
+
13
+ function splitIndent(line) {
14
+ const value = String(line || '');
15
+ const trimmed = value.trimStart();
16
+ return {
17
+ indent: value.slice(0, value.length - trimmed.length),
18
+ content: trimmed
19
+ };
20
+ }
21
+
22
+ function parseHeadingLine(line) {
23
+ const { content } = splitIndent(line);
24
+ if (content.startsWith('## ')) {
25
+ return content.slice(3).trim();
26
+ }
27
+ if (content.startsWith('### ')) {
28
+ return content.slice(4).trim();
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function parseTaskLine(line) {
34
+ const { indent, content } = splitIndent(line);
35
+ if (content.length < 6) return null;
36
+ if (content[0] !== '-' || content[1] !== ' ' || content[2] !== '[') return null;
37
+
38
+ const checkedToken = content[3];
39
+ if (checkedToken !== ' ' && checkedToken !== 'x' && checkedToken !== 'X') return null;
40
+ if (content[4] !== ']' || content[5] !== ' ') return null;
41
+
42
+ let text = content.slice(6).trimEnd();
43
+ let markerId = null;
44
+ let markerFlags = '';
45
+
46
+ const markerStart = text.lastIndexOf('<!--');
47
+ if (markerStart >= 0 && text.endsWith('-->')) {
48
+ const markerBody = text.slice(markerStart + 4, -3).trim();
49
+ if (markerBody.startsWith('rs:task=')) {
50
+ const markerPayload = markerBody.slice('rs:task='.length).trim();
51
+ const markerParts = markerPayload.split(/\s+/).filter(Boolean);
52
+ markerId = markerParts[0] || null;
53
+ markerFlags = markerParts.slice(1).join(' ');
54
+ text = text.slice(0, markerStart).trimEnd();
55
+ }
56
+ }
57
+
58
+ return {
59
+ indent,
60
+ checked: checkedToken.toLowerCase() === 'x',
61
+ text: text.trim(),
62
+ markerId,
63
+ markerFlags
64
+ };
65
+ }
66
+
67
+ function parseChildBulletLine(line) {
68
+ const { indent, content } = splitIndent(line);
69
+ if (!content.startsWith('- ')) {
70
+ return null;
71
+ }
72
+ return {
73
+ indent,
74
+ content: content.slice(2).trim()
75
+ };
76
+ }
77
+
78
+ function parseEvidenceLine(content) {
79
+ if (content.length < 9) return null;
80
+ if (content.slice(0, 9).toLowerCase() !== 'evidence:') return null;
81
+ return content.slice(9).trim();
82
+ }
83
+
84
+ function parseWarningLine(content) {
85
+ if (!content.startsWith(WARNING_PREFIX)) return null;
86
+ return content.slice(WARNING_PREFIX.length).trim();
87
+ }
11
88
 
12
89
  function parseRoadmap(content) {
13
90
  const lines = String(content || '').split(/\r?\n/);
@@ -17,31 +94,56 @@ function parseRoadmap(content) {
17
94
 
18
95
  for (let index = 0; index < lines.length; index += 1) {
19
96
  const line = lines[index];
20
- const headingMatch = line.match(HEADING_RE);
21
- if (headingMatch) {
22
- section = headingMatch[1].trim();
97
+ const headingText = parseHeadingLine(line);
98
+ if (headingText) {
99
+ section = headingText;
23
100
  }
24
101
 
25
- const taskMatch = line.match(TASK_LINE_RE);
26
- if (!taskMatch) {
102
+ const taskLine = parseTaskLine(line);
103
+ if (!taskLine) {
27
104
  continue;
28
105
  }
29
106
 
30
- const indent = taskMatch[1] || '';
31
- const checked = taskMatch[2].toLowerCase() === 'x';
32
- const text = taskMatch[3].trim();
33
- const markerId = taskMatch[4] || null;
34
- const markerFlags = taskMatch[5] || '';
107
+ const { indent, checked, text, markerId, markerFlags } = taskLine;
35
108
  const noTest = /\brs:no-test\b/i.test(markerFlags);
109
+ const taskIndentWidth = getIndentWidth(indent);
36
110
 
37
111
  let warningLineIndex = null;
38
112
  let warningText = null;
39
- if (index + 1 < lines.length) {
40
- const nextLine = lines[index + 1];
41
- const warningMatch = nextLine.match(WARNING_RE);
42
- if (warningMatch) {
43
- warningLineIndex = index + 1;
44
- warningText = warningMatch[1].trim();
113
+ const evidenceLines = [];
114
+ let lastChildLineIndex = index;
115
+ for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) {
116
+ const childLine = lines[childIndex];
117
+ if (!childLine.trim()) {
118
+ break;
119
+ }
120
+ if (parseHeadingLine(childLine) || parseTaskLine(childLine)) {
121
+ break;
122
+ }
123
+
124
+ const childBullet = parseChildBulletLine(childLine);
125
+ if (!childBullet) {
126
+ break;
127
+ }
128
+ if (getIndentWidth(childBullet.indent || '') <= taskIndentWidth) {
129
+ break;
130
+ }
131
+
132
+ lastChildLineIndex = childIndex;
133
+
134
+ const evidenceText = parseEvidenceLine(childBullet.content);
135
+ if (evidenceText != null) {
136
+ evidenceLines.push({
137
+ lineIndex: childIndex,
138
+ text: evidenceText,
139
+ raw: childLine
140
+ });
141
+ }
142
+
143
+ const warningTextValue = parseWarningLine(childBullet.content);
144
+ if (warningTextValue != null) {
145
+ warningLineIndex = childIndex;
146
+ warningText = warningTextValue;
45
147
  }
46
148
  }
47
149
 
@@ -51,8 +153,10 @@ function parseRoadmap(content) {
51
153
  text,
52
154
  checked,
53
155
  lineIndex: index,
156
+ lastChildLineIndex,
54
157
  warningLineIndex,
55
158
  warningText,
159
+ evidenceLines,
56
160
  markerId,
57
161
  noTest,
58
162
  indent,
package/src/sync/index.js CHANGED
@@ -34,6 +34,7 @@ function applySync(content, parsedTasks, results) {
34
34
  const warningText = formatWarning(task.indent || '', reason || 'validation failed');
35
35
  const hasWarning = task.warningLineIndex != null;
36
36
  const warningIndex = hasWarning ? task.warningLineIndex + offset : null;
37
+ const lastChildLineIndex = (task.lastChildLineIndex != null ? task.lastChildLineIndex : task.lineIndex) + offset;
37
38
 
38
39
  if (result.passed || !result.attempted) {
39
40
  if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
@@ -46,7 +47,7 @@ function applySync(content, parsedTasks, results) {
46
47
  if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
47
48
  lines[warningIndex] = warningText;
48
49
  } else {
49
- lines.splice(lineIndex + 1, 0, warningText);
50
+ lines.splice(lastChildLineIndex + 1, 0, warningText);
50
51
  offset += 1;
51
52
  }
52
53
  }
@@ -206,6 +206,49 @@ function hasKnownFileExtension(token) {
206
206
  return KNOWN_FILE_EXTENSIONS.has(token.slice(lastDot).toLowerCase());
207
207
  }
208
208
 
209
+ function isAsciiAlphaNumeric(char) {
210
+ if (!char || char.length === 0) return false;
211
+ const code = char.charCodeAt(0);
212
+ return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
213
+ }
214
+
215
+ function isPathTokenCharacter(char) {
216
+ return isAsciiAlphaNumeric(char) || char === '.' || char === '_' || char === '-' || char === '/' || char === '\\';
217
+ }
218
+
219
+ function stripTrailingPathPunctuation(token) {
220
+ let result = String(token || '');
221
+ while (result.length > 0) {
222
+ const lastChar = result[result.length - 1];
223
+ if (lastChar !== '.' && lastChar !== ',' && lastChar !== ';' && lastChar !== ':' && lastChar !== '!' && lastChar !== '?' && lastChar !== ')') {
224
+ break;
225
+ }
226
+ result = result.slice(0, -1);
227
+ }
228
+ return result;
229
+ }
230
+
231
+ function collectPathishTokens(text) {
232
+ const tokens = [];
233
+ let current = '';
234
+ const source = String(text || '');
235
+ for (let index = 0; index < source.length; index += 1) {
236
+ const char = source[index];
237
+ if (isPathTokenCharacter(char)) {
238
+ current += char;
239
+ continue;
240
+ }
241
+ if (current) {
242
+ tokens.push(stripTrailingPathPunctuation(current));
243
+ current = '';
244
+ }
245
+ }
246
+ if (current) {
247
+ tokens.push(stripTrailingPathPunctuation(current));
248
+ }
249
+ return tokens.filter(Boolean);
250
+ }
251
+
209
252
  function extractExplicitPaths(text) {
210
253
  const results = new Set();
211
254
  const quoted = String(text).match(/`([^`]+)`/g) || [];
@@ -218,7 +261,7 @@ function extractExplicitPaths(text) {
218
261
 
219
262
  const pathTokens = String(text).match(/([A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+)/g) || [];
220
263
  for (const raw of pathTokens) {
221
- const token = raw.replace(/[.,;:!?)]+$/, '');
264
+ const token = stripTrailingPathPunctuation(raw);
222
265
  if (isLikelyPath(token)) results.add(token);
223
266
  }
224
267
 
@@ -235,7 +278,7 @@ function extractStandaloneFilenames(text) {
235
278
  STANDALONE_FILE_RE.lastIndex = 0;
236
279
  let m = STANDALONE_FILE_RE.exec(String(text));
237
280
  while (m) {
238
- const token = m[1].replace(/[.,;:!?)]+$/, '');
281
+ const token = stripTrailingPathPunctuation(m[1]);
239
282
  if (hasKnownFileExtension(token) && !token.startsWith('.')) {
240
283
  results.add(token);
241
284
  }
@@ -576,6 +619,207 @@ function findArtifactEvidence(taskText, fileIndex) {
576
619
  return { files: files.slice(0, 20), heuristicArtifacts };
577
620
  }
578
621
 
622
+ function unionArrays(...values) {
623
+ return Array.from(new Set(values.flat().filter(Boolean)));
624
+ }
625
+
626
+ function isTestPath(relativePath) {
627
+ return /(^|\/)(__tests__|tests)\//.test(relativePath) || /\.test\.|\.spec\.|_test\.go$/.test(relativePath);
628
+ }
629
+
630
+ function extractEvidencePaths(evidenceText) {
631
+ const paths = new Set();
632
+ for (const rawCandidate of collectPathishTokens(evidenceText)) {
633
+ const candidate = rawCandidate.split('\\').join('/');
634
+ if (!candidate.includes('/') || candidate.includes('*') || candidate.includes('?')) {
635
+ continue;
636
+ }
637
+ if (!hasKnownFileExtension(candidate)) {
638
+ continue;
639
+ }
640
+ paths.add(candidate.replace(/^\.\//, ''));
641
+ }
642
+ return Array.from(paths).sort((left, right) => left.localeCompare(right));
643
+ }
644
+
645
+ function evidenceLineHasPassingSummary(evidenceText) {
646
+ const text = String(evidenceText || '');
647
+ if (/\b\d+\s*\/\s*\d+\s+tests?\s+passing\b/i.test(text)) {
648
+ return true;
649
+ }
650
+ if (/\b(?:vitest|jest|npm test|pnpm test|yarn test|bun test)\b.*\b(?:pass(?:ing|ed)?|green|success(?:ful|fully)?)\b/i.test(text)) {
651
+ return true;
652
+ }
653
+ return false;
654
+ }
655
+
656
+ function evidenceSummaryImpliesTests(evidenceText) {
657
+ return /\btests?\b/i.test(String(evidenceText || '')) ||
658
+ /\b(?:vitest|jest|npm test|pnpm test|yarn test|bun test)\b/i.test(String(evidenceText || ''));
659
+ }
660
+
661
+ function evaluateAuthoritativeEvidence(task, fileIndex) {
662
+ const evidenceLines = Array.isArray(task.evidenceLines) ? task.evidenceLines : [];
663
+ if (evidenceLines.length === 0) {
664
+ return {
665
+ active: false,
666
+ passed: false,
667
+ confidence: null,
668
+ referencedPaths: [],
669
+ matchedPaths: [],
670
+ summaryMatches: [],
671
+ summaryImpliesTests: false,
672
+ hasExtractedPaths: false
673
+ };
674
+ }
675
+
676
+ const referencedPaths = unionArrays(...evidenceLines.map((line) => extractEvidencePaths(line.text)));
677
+ const matchedPaths = referencedPaths.length > 0 ? findFilesByPathHints(referencedPaths, fileIndex) : [];
678
+ const summaryMatches = evidenceLines
679
+ .filter((line) => evidenceLineHasPassingSummary(line.text))
680
+ .map((line) => line.text);
681
+ const summaryImpliesTests = summaryMatches.some((line) => evidenceSummaryImpliesTests(line));
682
+ const matchedTestPaths = matchedPaths.filter((relativePath) => isTestPath(relativePath));
683
+ const matchedNonTestPaths = matchedPaths.filter((relativePath) => !isTestPath(relativePath));
684
+
685
+ if (referencedPaths.length > 0) {
686
+ if (matchedPaths.length === 0) {
687
+ return {
688
+ active: true,
689
+ passed: false,
690
+ confidence: 'low',
691
+ referencedPaths,
692
+ matchedPaths: [],
693
+ summaryMatches: [],
694
+ summaryImpliesTests: false,
695
+ hasExtractedPaths: true,
696
+ reasons: [`evidence file(s) not found: ${referencedPaths.join(', ')}`]
697
+ };
698
+ }
699
+
700
+ return {
701
+ active: true,
702
+ passed: true,
703
+ confidence: matchedTestPaths.length > 0 && matchedNonTestPaths.length > 0 ? 'high' : 'medium',
704
+ referencedPaths,
705
+ matchedPaths,
706
+ summaryMatches: [],
707
+ summaryImpliesTests: matchedTestPaths.length > 0,
708
+ hasExtractedPaths: true,
709
+ reasons: []
710
+ };
711
+ }
712
+
713
+ if (summaryMatches.length > 0) {
714
+ return {
715
+ active: true,
716
+ passed: true,
717
+ confidence: 'medium',
718
+ referencedPaths: [],
719
+ matchedPaths: [],
720
+ summaryMatches,
721
+ summaryImpliesTests,
722
+ hasExtractedPaths: false,
723
+ reasons: []
724
+ };
725
+ }
726
+
727
+ return {
728
+ active: true,
729
+ passed: false,
730
+ confidence: null,
731
+ referencedPaths: [],
732
+ matchedPaths: [],
733
+ summaryMatches: [],
734
+ summaryImpliesTests: false,
735
+ hasExtractedPaths: false,
736
+ reasons: []
737
+ };
738
+ }
739
+
740
+ function applyAuthoritativeEvidence(evidence, authoritativeEvidence, fileIndex) {
741
+ evidence.authoritative = authoritativeEvidence.passed;
742
+ evidence.authoritativeFiles = authoritativeEvidence.matchedPaths;
743
+ evidence.authoritativeSummaries = authoritativeEvidence.summaryMatches;
744
+ evidence.files = unionArrays(evidence.files, authoritativeEvidence.matchedPaths);
745
+
746
+ const indexedFiles = new Map(fileIndex.map((file) => [file.relativePath, file]));
747
+ for (const relativePath of authoritativeEvidence.matchedPaths) {
748
+ const file = indexedFiles.get(relativePath);
749
+ if (!file) {
750
+ continue;
751
+ }
752
+ if (file.isTestFile || isTestPath(relativePath)) {
753
+ evidence.test = true;
754
+ evidence.testFiles = unionArrays(evidence.testFiles, [relativePath]);
755
+ continue;
756
+ }
757
+ if (CODE_EXTENSIONS.has(file.ext)) {
758
+ evidence.code = true;
759
+ evidence.codeFiles = unionArrays(evidence.codeFiles, [relativePath]);
760
+ continue;
761
+ }
762
+ evidence.artifact = true;
763
+ evidence.artifactFiles = unionArrays(evidence.artifactFiles, [relativePath]);
764
+ }
765
+
766
+ if (authoritativeEvidence.summaryImpliesTests) {
767
+ evidence.test = true;
768
+ }
769
+ }
770
+
771
+ function countStrongEvidenceCategories(taskText, evidence) {
772
+ if (isDocTask(taskText)) {
773
+ return {
774
+ categories: [
775
+ ...(evidence.artifact ? ['artifact'] : []),
776
+ ...(evidence.files.length > 0 ? ['referenced-file'] : []),
777
+ ...(evidence.code ? ['code'] : []),
778
+ ...(evidence.test ? ['test'] : [])
779
+ ]
780
+ };
781
+ }
782
+
783
+ const categories = [];
784
+ if (evidence.code) {
785
+ categories.push('code');
786
+ }
787
+ if (evidence.test) {
788
+ categories.push('test');
789
+ }
790
+ if (evidence.files.length > 0 || evidence.symbols.length > 0 || evidence.structuralEvidence === true) {
791
+ categories.push('feature-surface');
792
+ }
793
+ return { categories };
794
+ }
795
+
796
+ function findNegativeImplementationSignals(candidatePaths, fileIndex) {
797
+ if (!Array.isArray(candidatePaths) || candidatePaths.length === 0) {
798
+ return [];
799
+ }
800
+
801
+ const indexedFiles = new Map(fileIndex.map((file) => [file.relativePath, file]));
802
+ const negativeSignals = [
803
+ /\bTODO\b/i,
804
+ /\bFIXME\b/i,
805
+ /\bdisabled\b/i,
806
+ /\bnot implemented\b/i,
807
+ /throw\s+new\s+Error\s*\(\s*['"`][^'"`]*not implemented/i
808
+ ];
809
+
810
+ const matches = [];
811
+ for (const relativePath of unionArrays(candidatePaths)) {
812
+ const file = indexedFiles.get(relativePath);
813
+ if (!file || file.isTestFile || !CODE_EXTENSIONS.has(file.ext)) {
814
+ continue;
815
+ }
816
+ if (negativeSignals.some((pattern) => pattern.test(file.content))) {
817
+ matches.push(relativePath);
818
+ }
819
+ }
820
+ return matches.sort((left, right) => left.localeCompare(right));
821
+ }
822
+
579
823
  function extractTaskNamespace(taskId) {
580
824
  if (!taskId) return null;
581
825
  const match = String(taskId).match(/^([a-z][a-z0-9]*)-/);
@@ -771,6 +1015,7 @@ function validateTask(task, context, config, plugins) {
771
1015
  const pathHints = extractExplicitPaths(task.text);
772
1016
  const standaloneFilenames = extractStandaloneFilenames(task.text);
773
1017
  const symbolHints = extractSymbolHints(task.text);
1018
+ const authoritativeEvidence = evaluateAuthoritativeEvidence(task, context.fileIndex);
774
1019
 
775
1020
  const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
776
1021
  const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
@@ -800,12 +1045,19 @@ function validateTask(task, context, config, plugins) {
800
1045
  heuristicArtifacts,
801
1046
  structuralEvidence: structuralCheck.applicable ? structuralCheck.passed : null,
802
1047
  structuralFiles: structuralCheck.structuralFiles,
1048
+ authoritative: false,
1049
+ authoritativeFiles: [],
1050
+ authoritativeSummaries: []
803
1051
  };
1052
+ applyAuthoritativeEvidence(evidence, authoritativeEvidence, context.fileIndex);
804
1053
 
805
1054
  const reasons = [];
806
1055
  if (pathHints.length > 0 && filesFromPaths.length === 0) {
807
1056
  reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
808
1057
  }
1058
+ if (Array.isArray(authoritativeEvidence.reasons) && authoritativeEvidence.reasons.length > 0) {
1059
+ reasons.push(...authoritativeEvidence.reasons);
1060
+ }
809
1061
  if (symbolHints.length > 0 && filesFromSymbols.length === 0) {
810
1062
  reasons.push(`missing symbol(s): ${symbolHints.join(', ')}`);
811
1063
  }
@@ -818,9 +1070,9 @@ function validateTask(task, context, config, plugins) {
818
1070
 
819
1071
  const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
820
1072
  const hasWeakEvidence = filesFromWeakPathTokens.length > 0;
821
- if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable) {
1073
+ if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable && !authoritativeEvidence.hasExtractedPaths && !authoritativeEvidence.passed) {
822
1074
  reasons.push('no code, test, or artifact evidence found');
823
- } else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed) {
1075
+ } else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed && !authoritativeEvidence.hasExtractedPaths && !authoritativeEvidence.passed) {
824
1076
  reasons.push('no code, test, or artifact evidence found');
825
1077
  } else if (!hasEvidence && hasWeakEvidence) {
826
1078
  if (weakPathContentTokens.length === 0) {
@@ -834,9 +1086,11 @@ function validateTask(task, context, config, plugins) {
834
1086
  const configuredRules = Array.isArray(config.validators) ? config.validators : [];
835
1087
  const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
836
1088
  let overrideResult = null;
1089
+ let hasRuleGrantedEvidence = false;
837
1090
  for (const rule of [...configuredRules, ...pluginRules]) {
838
1091
  const ruleResult = evaluateRule(rule, task, context);
839
1092
  if (ruleResult.evidence && Object.keys(ruleResult.evidence).length > 0) {
1093
+ hasRuleGrantedEvidence = true;
840
1094
  Object.assign(evidence, mergeRuleEvidence(evidence, ruleResult.evidence));
841
1095
  }
842
1096
  if (!ruleResult.passed) {
@@ -856,7 +1110,7 @@ function validateTask(task, context, config, plugins) {
856
1110
  }
857
1111
  }
858
1112
 
859
- if (requiresTest && !evidence.test) {
1113
+ if (requiresTest && !evidence.test && !authoritativeEvidence.passed) {
860
1114
  reasons.push('missing test evidence');
861
1115
  }
862
1116
 
@@ -866,12 +1120,50 @@ function validateTask(task, context, config, plugins) {
866
1120
  uniqueReasons = Array.isArray(overrideResult.reasons) ? Array.from(new Set(overrideResult.reasons)) : [];
867
1121
  }
868
1122
 
869
- const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0;
1123
+ const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0 || authoritativeEvidence.active;
1124
+ const { categories: strongEvidenceCategories } = countStrongEvidenceCategories(task.text, evidence);
1125
+ const strongEvidenceCount = strongEvidenceCategories.length;
1126
+ const hasDirectReferencePass = filesFromPaths.length > 0 || filesFromSymbols.length > 0;
1127
+ const hasArtifactTaskPass = evidence.artifact && (
1128
+ isDocTask(task.text) ||
1129
+ evidence.heuristicArtifacts.length > 0 ||
1130
+ filesFromPaths.some((relativePath) => !CODE_EXTENSIONS.has(path.extname(relativePath).toLowerCase()))
1131
+ );
1132
+ const hasTrustedRuleEvidencePass = hasRuleGrantedEvidence && uniqueReasons.length === 0;
1133
+ const meetsStrongThreshold = !isDocTask(task.text) && strongEvidenceCount >= 2;
1134
+
1135
+ let confidence = 'low';
1136
+ if (authoritativeEvidence.passed) {
1137
+ confidence = authoritativeEvidence.confidence || 'medium';
1138
+ } else if (meetsStrongThreshold) {
1139
+ confidence = 'high';
1140
+ } else if (strongEvidenceCount === 1 || hasDirectReferencePass || hasArtifactTaskPass || hasTrustedRuleEvidencePass) {
1141
+ confidence = 'medium';
1142
+ }
1143
+
1144
+ const negativeSignalMatches = findNegativeImplementationSignals(
1145
+ unionArrays(
1146
+ evidence.codeFiles,
1147
+ evidence.files,
1148
+ evidence.symbols,
1149
+ evidence.weakPathFiles,
1150
+ evidence.structuralFiles
1151
+ ),
1152
+ context.fileIndex
1153
+ );
1154
+ if (negativeSignalMatches.length > 0) {
1155
+ uniqueReasons.push(`negative implementation signal found in matched evidence: ${negativeSignalMatches.join(', ')}`);
1156
+ uniqueReasons = Array.from(new Set(uniqueReasons));
1157
+ }
870
1158
 
871
- const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
872
- let confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
873
- if (confidence === 'low' && hasWeakEvidence) {
874
- confidence = 'low';
1159
+ let passed = authoritativeEvidence.passed || hasDirectReferencePass || hasArtifactTaskPass || hasTrustedRuleEvidencePass || meetsStrongThreshold;
1160
+ if (task.warningText && passed && !authoritativeEvidence.passed && !meetsStrongThreshold) {
1161
+ passed = false;
1162
+ uniqueReasons.push(task.warningText);
1163
+ uniqueReasons = Array.from(new Set(uniqueReasons));
1164
+ }
1165
+ if (negativeSignalMatches.length > 0) {
1166
+ passed = false;
875
1167
  }
876
1168
 
877
1169
  // True when the only passing evidence is artifact/doc files and the task is not a doc task.
@@ -880,7 +1172,7 @@ function validateTask(task, context, config, plugins) {
880
1172
 
881
1173
  return {
882
1174
  taskId: task.id,
883
- passed: overrideResult ? overrideResult.passed !== false : uniqueReasons.length === 0,
1175
+ passed: overrideResult ? overrideResult.passed !== false : (passed && uniqueReasons.length === 0),
884
1176
  confidence,
885
1177
  reasons: uniqueReasons,
886
1178
  evidence,