@promptci/core 0.0.1
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/LICENSE +201 -0
- package/README.md +30 -0
- package/dist/agent-practices.d.ts +22 -0
- package/dist/agent-practices.d.ts.map +1 -0
- package/dist/agent-practices.js +582 -0
- package/dist/agent-practices.js.map +1 -0
- package/dist/baseline.d.ts +40 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +172 -0
- package/dist/baseline.js.map +1 -0
- package/dist/canonical-owner.d.ts +3 -0
- package/dist/canonical-owner.d.ts.map +1 -0
- package/dist/canonical-owner.js +131 -0
- package/dist/canonical-owner.js.map +1 -0
- package/dist/cheap-model-routing.d.ts +4 -0
- package/dist/cheap-model-routing.d.ts.map +1 -0
- package/dist/cheap-model-routing.js +43 -0
- package/dist/cheap-model-routing.js.map +1 -0
- package/dist/ci-alignment.d.ts +10 -0
- package/dist/ci-alignment.d.ts.map +1 -0
- package/dist/ci-alignment.js +343 -0
- package/dist/ci-alignment.js.map +1 -0
- package/dist/cli-output-filter.d.ts +16 -0
- package/dist/cli-output-filter.d.ts.map +1 -0
- package/dist/cli-output-filter.js +88 -0
- package/dist/cli-output-filter.js.map +1 -0
- package/dist/command-validity.d.ts +12 -0
- package/dist/command-validity.d.ts.map +1 -0
- package/dist/command-validity.js +543 -0
- package/dist/command-validity.js.map +1 -0
- package/dist/conflicts.d.ts +49 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +1065 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/context-analysis.d.ts +15 -0
- package/dist/context-analysis.d.ts.map +1 -0
- package/dist/context-analysis.js +24 -0
- package/dist/context-analysis.js.map +1 -0
- package/dist/context-bloat.d.ts +17 -0
- package/dist/context-bloat.d.ts.map +1 -0
- package/dist/context-bloat.js +148 -0
- package/dist/context-bloat.js.map +1 -0
- package/dist/context-cost.d.ts +4 -0
- package/dist/context-cost.d.ts.map +1 -0
- package/dist/context-cost.js +70 -0
- package/dist/context-cost.js.map +1 -0
- package/dist/context-optimizer.d.ts +14 -0
- package/dist/context-optimizer.d.ts.map +1 -0
- package/dist/context-optimizer.js +219 -0
- package/dist/context-optimizer.js.map +1 -0
- package/dist/context-recommender.d.ts +3 -0
- package/dist/context-recommender.d.ts.map +1 -0
- package/dist/context-recommender.js +329 -0
- package/dist/context-recommender.js.map +1 -0
- package/dist/dead-references.d.ts +33 -0
- package/dist/dead-references.d.ts.map +1 -0
- package/dist/dead-references.js +607 -0
- package/dist/dead-references.js.map +1 -0
- package/dist/detectors.d.ts +11 -0
- package/dist/detectors.d.ts.map +1 -0
- package/dist/detectors.js +160 -0
- package/dist/detectors.js.map +1 -0
- package/dist/duplicates.d.ts +36 -0
- package/dist/duplicates.d.ts.map +1 -0
- package/dist/duplicates.js +436 -0
- package/dist/duplicates.js.map +1 -0
- package/dist/fix-engine.d.ts +16 -0
- package/dist/fix-engine.d.ts.map +1 -0
- package/dist/fix-engine.js +179 -0
- package/dist/fix-engine.js.map +1 -0
- package/dist/framework-packs.d.ts +8 -0
- package/dist/framework-packs.d.ts.map +1 -0
- package/dist/framework-packs.js +22 -0
- package/dist/framework-packs.js.map +1 -0
- package/dist/frameworks/dotnet.d.ts +4 -0
- package/dist/frameworks/dotnet.d.ts.map +1 -0
- package/dist/frameworks/dotnet.js +77 -0
- package/dist/frameworks/dotnet.js.map +1 -0
- package/dist/frameworks/go.d.ts +4 -0
- package/dist/frameworks/go.d.ts.map +1 -0
- package/dist/frameworks/go.js +39 -0
- package/dist/frameworks/go.js.map +1 -0
- package/dist/frameworks/nextjs.d.ts +4 -0
- package/dist/frameworks/nextjs.d.ts.map +1 -0
- package/dist/frameworks/nextjs.js +66 -0
- package/dist/frameworks/nextjs.js.map +1 -0
- package/dist/frameworks/python.d.ts +4 -0
- package/dist/frameworks/python.d.ts.map +1 -0
- package/dist/frameworks/python.js +66 -0
- package/dist/frameworks/python.js.map +1 -0
- package/dist/frameworks/rust.d.ts +4 -0
- package/dist/frameworks/rust.d.ts.map +1 -0
- package/dist/frameworks/rust.js +72 -0
- package/dist/frameworks/rust.js.map +1 -0
- package/dist/frameworks/unity.d.ts +4 -0
- package/dist/frameworks/unity.d.ts.map +1 -0
- package/dist/frameworks/unity.js +74 -0
- package/dist/frameworks/unity.js.map +1 -0
- package/dist/health-score.d.ts +37 -0
- package/dist/health-score.d.ts.map +1 -0
- package/dist/health-score.js +164 -0
- package/dist/health-score.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/llm.d.ts +6 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +67 -0
- package/dist/llm.js.map +1 -0
- package/dist/manifest-consistency.d.ts +37 -0
- package/dist/manifest-consistency.d.ts.map +1 -0
- package/dist/manifest-consistency.js +659 -0
- package/dist/manifest-consistency.js.map +1 -0
- package/dist/mcp-detector.d.ts +4 -0
- package/dist/mcp-detector.d.ts.map +1 -0
- package/dist/mcp-detector.js +131 -0
- package/dist/mcp-detector.js.map +1 -0
- package/dist/missing-context.d.ts +15 -0
- package/dist/missing-context.d.ts.map +1 -0
- package/dist/missing-context.js +517 -0
- package/dist/missing-context.js.map +1 -0
- package/dist/native-tool-preference.d.ts +20 -0
- package/dist/native-tool-preference.d.ts.map +1 -0
- package/dist/native-tool-preference.js +94 -0
- package/dist/native-tool-preference.js.map +1 -0
- package/dist/output-verbosity.d.ts +3 -0
- package/dist/output-verbosity.d.ts.map +1 -0
- package/dist/output-verbosity.js +83 -0
- package/dist/output-verbosity.js.map +1 -0
- package/dist/product-boundary.d.ts +11 -0
- package/dist/product-boundary.d.ts.map +1 -0
- package/dist/product-boundary.js +301 -0
- package/dist/product-boundary.js.map +1 -0
- package/dist/project-type.d.ts +15 -0
- package/dist/project-type.d.ts.map +1 -0
- package/dist/project-type.js +116 -0
- package/dist/project-type.js.map +1 -0
- package/dist/prompt-cache.d.ts +3 -0
- package/dist/prompt-cache.d.ts.map +1 -0
- package/dist/prompt-cache.js +151 -0
- package/dist/prompt-cache.js.map +1 -0
- package/dist/repo-context.d.ts +37 -0
- package/dist/repo-context.d.ts.map +1 -0
- package/dist/repo-context.js +221 -0
- package/dist/repo-context.js.map +1 -0
- package/dist/report.d.ts +87 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +558 -0
- package/dist/report.js.map +1 -0
- package/dist/scan.d.ts +7 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +43 -0
- package/dist/scan.js.map +1 -0
- package/dist/scanner.d.ts +3 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +216 -0
- package/dist/scanner.js.map +1 -0
- package/dist/security-pack.d.ts +4 -0
- package/dist/security-pack.d.ts.map +1 -0
- package/dist/security-pack.js +403 -0
- package/dist/security-pack.js.map +1 -0
- package/dist/stale-instructions.d.ts +20 -0
- package/dist/stale-instructions.d.ts.map +1 -0
- package/dist/stale-instructions.js +269 -0
- package/dist/stale-instructions.js.map +1 -0
- package/dist/suppression.d.ts +64 -0
- package/dist/suppression.d.ts.map +1 -0
- package/dist/suppression.js +338 -0
- package/dist/suppression.js.map +1 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/vague-guidance.d.ts +12 -0
- package/dist/vague-guidance.d.ts.map +1 -0
- package/dist/vague-guidance.js +235 -0
- package/dist/vague-guidance.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict detector for PromptCI.
|
|
3
|
+
*
|
|
4
|
+
* Looks for contradictory directive pairs within or across instruction files:
|
|
5
|
+
* "use X" vs "do not use X"
|
|
6
|
+
* "always X" vs "never X"
|
|
7
|
+
* "must X" vs "avoid X"
|
|
8
|
+
* "prefer X" vs "avoid X"
|
|
9
|
+
*
|
|
10
|
+
* Only flags pairs where the extracted subject noun/phrase is the same or
|
|
11
|
+
* highly similar — unrelated sentences that happen to contain "use" and
|
|
12
|
+
* "avoid" on different topics are NOT flagged.
|
|
13
|
+
*
|
|
14
|
+
* Wording is intentionally cautious — all findings are heuristic.
|
|
15
|
+
*/
|
|
16
|
+
import * as crypto from 'node:crypto';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
/**
|
|
19
|
+
* C2: previously a 50-char floor dropped every section shorter than that —
|
|
20
|
+
* including terse, information-dense one-liner rule files like
|
|
21
|
+
* "Never use Dapper." (18 chars), silently hiding them from cross-file
|
|
22
|
+
* comparison. Directive-pattern matching plus the subject-length/generic
|
|
23
|
+
* checks in extractDirectives already filter noise (a bare heading or empty
|
|
24
|
+
* body simply produces zero matches), so the floor only needs to reject
|
|
25
|
+
* genuinely-empty sections, not merely short ones.
|
|
26
|
+
*/
|
|
27
|
+
const MIN_SECTION_LENGTH = 1; // chars — effectively "non-empty", see comment above
|
|
28
|
+
// Stop-words that signal the subject noun phrase has ended.
|
|
29
|
+
const STOP_WORDS = new Set([
|
|
30
|
+
'for', 'to', 'when', 'in', 'with', 'as', 'from', 'the', 'and', 'or',
|
|
31
|
+
'but', 'if', 'are', 'is', 'has', 'have', 'had', 'will', 'was', 'were',
|
|
32
|
+
'by', 'of', 'at', 'on', 'an', 'a', 'all', 'any', 'this', 'that',
|
|
33
|
+
'these', 'those', 'our', 'your', 'their', 'its', 'so', 'than', 'be',
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* BUG-011: Scope-qualifier words — a subset of STOP_WORDS that, when they
|
|
37
|
+
* terminate subject extraction on a NEGATIVE directive, indicate the prohibition
|
|
38
|
+
* is scoped ("don't use X for Y" / "never use X when Y") rather than absolute.
|
|
39
|
+
* Scoped prohibitions often complement a positive directive and should not be
|
|
40
|
+
* flagged as hard conflicts.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately narrow: "for", "when", "if", "unless", "except".
|
|
43
|
+
* "in" is NOT included — "never use Jest in this project" is an absolute
|
|
44
|
+
* prohibition ("in this project" = the whole scope, not a sub-context).
|
|
45
|
+
*/
|
|
46
|
+
const QUALIFIER_STOP_WORDS = new Set(['for', 'when', 'if', 'unless', 'except']);
|
|
47
|
+
/**
|
|
48
|
+
* Generic verbs/adjectives that produce low-value conflict subjects.
|
|
49
|
+
* A subject consisting entirely of these words is too vague to flag.
|
|
50
|
+
*/
|
|
51
|
+
const GENERIC_SUBJECTS = new Set([
|
|
52
|
+
'it', 'them', 'they', 'we', 'code', 'sure', 'careful', 'good', 'clean',
|
|
53
|
+
'simple', 'easy', 'proper', 'consistent', 'clear', 'correct', 'possible',
|
|
54
|
+
'necessary', 'appropriate',
|
|
55
|
+
]);
|
|
56
|
+
/**
|
|
57
|
+
* Common directive verbs that appear immediately after "always/never/must/prefer".
|
|
58
|
+
* When extracting a subject for those pairs, we skip the first word if it's in
|
|
59
|
+
* this set so that "always use pandas" → subject "pandas", not "use".
|
|
60
|
+
* Without this, "always use X" and "always use Y" (for different X and Y)
|
|
61
|
+
* both extract subject "use" and falsely look like a conflict.
|
|
62
|
+
*
|
|
63
|
+
* NOTE: This skip is NOT applied for the "use / do not use" pair because
|
|
64
|
+
* there "use" is the directive keyword itself, not a trailing verb.
|
|
65
|
+
*/
|
|
66
|
+
const LEADING_DIRECTIVE_VERBS = new Set([
|
|
67
|
+
'use', 'run', 'write', 'call', 'create', 'add', 'implement', 'put',
|
|
68
|
+
'keep', 'follow', 'apply', 'make', 'define', 'ensure', 'check', 'test',
|
|
69
|
+
'build', 'deploy', 'configure', 'handle', 'prefer', 'avoid', 'include',
|
|
70
|
+
'return', 'pass', 'store', 'log', 'load', 'fetch', 'set', 'get', 'import',
|
|
71
|
+
]);
|
|
72
|
+
/**
|
|
73
|
+
* Extract up to `maxWords` content words after an anchor position,
|
|
74
|
+
* stopping at stop-words or non-word characters.
|
|
75
|
+
*
|
|
76
|
+
* @param skipLeadingVerb - when true, skip the first word if it is a common
|
|
77
|
+
* directive verb (e.g. "use" in "always use pandas" → subject "pandas").
|
|
78
|
+
* Should be true for always/never/must/prefer pairs but false for the
|
|
79
|
+
* "use / do not use" pair where "use" is already the anchor keyword.
|
|
80
|
+
*/
|
|
81
|
+
function extractSubjectWords(line, anchorIndex, maxWords = 3, skipLeadingVerb = false) {
|
|
82
|
+
// Consume the rest of the line after the anchor
|
|
83
|
+
let rest = line.slice(anchorIndex).replace(/^\s+/, '');
|
|
84
|
+
// Optionally skip a leading directive verb so "always use X" → subject "X"
|
|
85
|
+
if (skipLeadingVerb) {
|
|
86
|
+
const firstWordMatch = /^([a-zA-Z]+)\s+/.exec(rest);
|
|
87
|
+
if (firstWordMatch && LEADING_DIRECTIVE_VERBS.has(firstWordMatch[1].toLowerCase())) {
|
|
88
|
+
rest = rest.slice(firstWordMatch[0].length);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const words = [];
|
|
92
|
+
// Truncate at em/en dashes, commas, semicolons, colons, periods, and
|
|
93
|
+
// parenthetical content ("Cobra (github.com/cobra)" → "Cobra").
|
|
94
|
+
// Parentheticals often contain package paths or URLs that become noise tokens.
|
|
95
|
+
const truncated = rest
|
|
96
|
+
.replace(/[—–].*$/, '')
|
|
97
|
+
.replace(/\s*\(.*$/, '') // drop "(…)" commentary before further truncation
|
|
98
|
+
.replace(/[,;:.].*$/, '')
|
|
99
|
+
.trim();
|
|
100
|
+
for (const raw of truncated.split(/\s+/)) {
|
|
101
|
+
const word = raw.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '').toLowerCase();
|
|
102
|
+
if (!word)
|
|
103
|
+
continue;
|
|
104
|
+
if (STOP_WORDS.has(word))
|
|
105
|
+
break;
|
|
106
|
+
// C4: accept digit-LEADING tech names ("11ty", "7zip", "3proxy") — only
|
|
107
|
+
// abort on a word that's letter-less entirely (a bare version number like
|
|
108
|
+
// "16" or "2024"), not merely digit-first.
|
|
109
|
+
if (!/^[a-z]/i.test(word) && !/^[0-9]+[a-z]/i.test(word))
|
|
110
|
+
break;
|
|
111
|
+
words.push(word);
|
|
112
|
+
if (words.length >= maxWords)
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
return words.join(' ');
|
|
116
|
+
}
|
|
117
|
+
const DIRECTIVE_PAIRS = [
|
|
118
|
+
{
|
|
119
|
+
label: 'use / do not use',
|
|
120
|
+
positivePattern: /\buse\s+/i,
|
|
121
|
+
// BUG-003: extend to also catch em/en-dash "— not X" negation (e.g. "use urfave/cli — not Cobra")
|
|
122
|
+
negativePattern: /\b(?:do\s+not|don['']t|never)\s+use\s+|[—–]\s*not\s+/i,
|
|
123
|
+
// "use" IS the anchor keyword here — do not skip
|
|
124
|
+
skipLeadingVerb: false,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
// BUG-001: "migrating away from X" / "moving away from X" is a clear negative
|
|
128
|
+
// directive for X — even without explicit "do not use" language.
|
|
129
|
+
// Positive side re-uses the "use" pattern so "We use Redux Toolkit" is the
|
|
130
|
+
// counterpart to "We are migrating away from Redux".
|
|
131
|
+
// allowSingleTokenMatch: the positive often has "redux toolkit" (2 words) while
|
|
132
|
+
// the negative has "redux" (1 word, since the sentence ends after the library name).
|
|
133
|
+
label: 'use / migrating away from',
|
|
134
|
+
positivePattern: /\buse\s+/i,
|
|
135
|
+
negativePattern: /\b(?:migrat(?:ing|e|ed)\s+(?:away\s+from|off\s+of)\s+|moving\s+away\s+from\s+|switch(?:ing|ed)?\s+(?:away\s+from|off\s+of)\s+|dropping\s+)/i,
|
|
136
|
+
skipLeadingVerb: false,
|
|
137
|
+
allowSingleTokenMatch: true,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
// BUG-002: "X is acceptable in tests" is a positive scoped permission that
|
|
141
|
+
// can conflict with "Never use X" — e.g. unwrap() scoping between files.
|
|
142
|
+
// extractPositiveSubjectBefore: positive "X is acceptable" extracts before the copula;
|
|
143
|
+
// negative "Never use X" extracts AFTER the verb (handled by default).
|
|
144
|
+
label: 'acceptable / never',
|
|
145
|
+
positivePattern: /\b(?:is|are)\s+acceptable\b/i,
|
|
146
|
+
negativePattern: /\bnever\s+use\s+|\b(?:do\s+not|don['']t)\s+use\s+/i,
|
|
147
|
+
skipLeadingVerb: false,
|
|
148
|
+
extractPositiveSubjectBefore: true,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
// BUG-003 (eval): "This project uses X" / "Use X" vs "Avoid X" catches imperative
|
|
152
|
+
// contradictions like "Avoid Lombok" (CLAUDE.md) vs "This project uses Lombok extensively" (AGENTS.md).
|
|
153
|
+
// Uses? matches both "use" and "uses".
|
|
154
|
+
label: 'use / avoid',
|
|
155
|
+
positivePattern: /\buses?\s+/i,
|
|
156
|
+
negativePattern: /\bavoid\s+/i,
|
|
157
|
+
skipLeadingVerb: false,
|
|
158
|
+
allowSingleTokenMatch: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
// BUG-003: Catch declarative stack references like "CLI framework: Cobra" vs "not Cobra".
|
|
162
|
+
// positivePattern matches "keyword: Value" tech-stack declarations.
|
|
163
|
+
// Negative restricted to em/en-dash phrases ("— not X") and "not using X" to avoid
|
|
164
|
+
// false positives from contrast clauses like "use Jinja there, not Python".
|
|
165
|
+
label: 'declared stack / not',
|
|
166
|
+
positivePattern: /\b(?:framework|library|runtime|sdk|engine|package|tool|stack|lang(?:uage)?|cli)\b[^:\n]{0,20}:\s*/i,
|
|
167
|
+
negativePattern: /[—–]\s*not\s+|\bnot\s+using\s+/i,
|
|
168
|
+
skipLeadingVerb: false,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
label: 'always / never',
|
|
172
|
+
positivePattern: /\balways\s+/i,
|
|
173
|
+
negativePattern: /\bnever\s+/i,
|
|
174
|
+
// "always use X" → skip "use" → subject "X"
|
|
175
|
+
skipLeadingVerb: true,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
label: 'always / avoid',
|
|
179
|
+
positivePattern: /\balways\s+/i,
|
|
180
|
+
negativePattern: /\bavoid\s+/i,
|
|
181
|
+
// catches "always use pandas" vs "avoid pandas for large datasets"
|
|
182
|
+
skipLeadingVerb: true,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
label: 'must / avoid',
|
|
186
|
+
positivePattern: /\bmust\s+/i,
|
|
187
|
+
negativePattern: /\bavoid\s+/i,
|
|
188
|
+
skipLeadingVerb: true,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
label: 'prefer / avoid',
|
|
192
|
+
positivePattern: /\bprefer\s+/i,
|
|
193
|
+
negativePattern: /\bavoid\s+/i,
|
|
194
|
+
skipLeadingVerb: true,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
// BUG-005: Copula pattern — "X are/is preferred" vs "X are/is only acceptable".
|
|
198
|
+
// "preferred" is an endorsement; "only acceptable" restricts to an edge case.
|
|
199
|
+
// The subject comes BEFORE the copula ("are/is"), so we use extractSubjectBefore.
|
|
200
|
+
label: 'preferred / only acceptable',
|
|
201
|
+
positivePattern: /\b(?:is|are)\s+(?:generally\s+|usually\s+)?preferred\b/i,
|
|
202
|
+
negativePattern: /\b(?:is|are)\s+only\s+acceptable\b|\b(?:acceptable|allowed)\s+only\b/i,
|
|
203
|
+
skipLeadingVerb: false,
|
|
204
|
+
extractSubjectBefore: true,
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
/**
|
|
208
|
+
* BUG-011: After extracting a subject, detect whether the first stop-word that
|
|
209
|
+
* terminated extraction was a scope qualifier ("for", "when", "in", "if", …).
|
|
210
|
+
* If so, the directive is scoped ("don't use X for Y") rather than absolute.
|
|
211
|
+
*
|
|
212
|
+
* Works by re-walking the same token sequence that extractSubjectWords uses,
|
|
213
|
+
* so the result is guaranteed to be consistent.
|
|
214
|
+
*/
|
|
215
|
+
function detectQualifierAfterSubject(line, anchorIndex, subject, skipLeadingVerb) {
|
|
216
|
+
let rest = line.slice(anchorIndex).replace(/^\s+/, '');
|
|
217
|
+
if (skipLeadingVerb) {
|
|
218
|
+
const fw = /^([a-zA-Z]+)\s+/.exec(rest);
|
|
219
|
+
if (fw && LEADING_DIRECTIVE_VERBS.has(fw[1].toLowerCase())) {
|
|
220
|
+
rest = rest.slice(fw[0].length);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const truncated = rest
|
|
224
|
+
.replace(/[—–].*$/, '')
|
|
225
|
+
.replace(/\s*\(.*$/, '') // strip parenthetical noise (URLs, comments)
|
|
226
|
+
.replace(/[,;:.].*$/, '')
|
|
227
|
+
.trim();
|
|
228
|
+
const tokens = subject.split(' ').filter(Boolean);
|
|
229
|
+
let remaining = truncated;
|
|
230
|
+
// Consume the subject tokens from remaining
|
|
231
|
+
for (const tok of tokens) {
|
|
232
|
+
const idx = remaining.toLowerCase().indexOf(tok);
|
|
233
|
+
if (idx < 0)
|
|
234
|
+
return false;
|
|
235
|
+
remaining = remaining.slice(idx + tok.length).trimStart();
|
|
236
|
+
}
|
|
237
|
+
remaining = remaining.replace(/^[^a-zA-Z]+/, '');
|
|
238
|
+
// The next word is whatever terminated the subject extraction
|
|
239
|
+
const nextWord = /^([a-zA-Z]+)/.exec(remaining)?.[1]?.toLowerCase() ?? '';
|
|
240
|
+
return QUALIFIER_STOP_WORDS.has(nextWord);
|
|
241
|
+
}
|
|
242
|
+
function normalizeSubject(s) {
|
|
243
|
+
return s
|
|
244
|
+
.toLowerCase()
|
|
245
|
+
.replace(/[^\w\s]/g, '')
|
|
246
|
+
.replace(/\s+/g, ' ')
|
|
247
|
+
.trim();
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Return true if the subject is too generic to produce a meaningful conflict.
|
|
251
|
+
* Subjects must have at least one word that is not in the generic set.
|
|
252
|
+
*/
|
|
253
|
+
function isGenericSubject(subject) {
|
|
254
|
+
if (!subject || subject.length < 2)
|
|
255
|
+
return true;
|
|
256
|
+
const words = subject.split(' ').filter(Boolean);
|
|
257
|
+
if (words.length === 0)
|
|
258
|
+
return true;
|
|
259
|
+
// All words are generic → skip
|
|
260
|
+
return words.every((w) => GENERIC_SUBJECTS.has(w));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Word-overlap (Jaccard) similarity between two subject strings.
|
|
264
|
+
* Returns 0–1.
|
|
265
|
+
*/
|
|
266
|
+
function subjectSimilarity(a, b) {
|
|
267
|
+
const wa = new Set(a.split(' ').filter(Boolean));
|
|
268
|
+
const wb = new Set(b.split(' ').filter(Boolean));
|
|
269
|
+
if (wa.size === 0 || wb.size === 0)
|
|
270
|
+
return 0;
|
|
271
|
+
let intersection = 0;
|
|
272
|
+
for (const w of wa) {
|
|
273
|
+
if (wb.has(w))
|
|
274
|
+
intersection++;
|
|
275
|
+
}
|
|
276
|
+
const union = wa.size + wb.size - intersection;
|
|
277
|
+
return intersection / union;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* BUG-005: Extract subject words from BEFORE an anchor position.
|
|
281
|
+
* Used for copula sentences like "Synchronous handlers are preferred" where the
|
|
282
|
+
* subject precedes the predicate.
|
|
283
|
+
*
|
|
284
|
+
* Looks backward from `anchorIndex` to the nearest clause boundary (comma,
|
|
285
|
+
* period, or start of string), then extracts the last N content words.
|
|
286
|
+
*/
|
|
287
|
+
function extractSubjectWordsBefore(line, anchorIndex, maxWords = 4) {
|
|
288
|
+
const before = line.slice(0, anchorIndex).trimEnd();
|
|
289
|
+
if (!before)
|
|
290
|
+
return '';
|
|
291
|
+
// Find the nearest clause start (after a comma, period, semicolon, or start of line)
|
|
292
|
+
let clauseStart = 0;
|
|
293
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
294
|
+
if (/[,;.]/.test(before[i])) {
|
|
295
|
+
clauseStart = i + 1;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const clause = before.slice(clauseStart).trim();
|
|
300
|
+
const words = [];
|
|
301
|
+
// Work backwards through the clause words
|
|
302
|
+
for (const raw of clause.split(/\s+/).reverse()) {
|
|
303
|
+
const word = raw.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, '').toLowerCase();
|
|
304
|
+
if (!word)
|
|
305
|
+
continue;
|
|
306
|
+
if (STOP_WORDS.has(word))
|
|
307
|
+
continue;
|
|
308
|
+
words.unshift(word);
|
|
309
|
+
if (words.length >= maxWords)
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
return words.join(' ');
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* C3: build "logical lines" from a section's raw text — consecutive physical
|
|
316
|
+
* lines are merged into one logical line when the earlier line does NOT end
|
|
317
|
+
* with sentence-terminal punctuation (`.`/`!`/`?`/`:`/`;`), which is a strong
|
|
318
|
+
* signal of a hard line-wrap mid-directive ("Do not\nuse Cobra."). Blank
|
|
319
|
+
* lines and markdown table rows ("| Avoid | Use Instead |") always end a
|
|
320
|
+
* logical line and are never merged across, so unrelated paragraphs don't
|
|
321
|
+
* bleed into each other.
|
|
322
|
+
*
|
|
323
|
+
* This replaces per-physical-line iteration entirely (rather than testing
|
|
324
|
+
* both the original lines AND a joined version) so that a directive spanning
|
|
325
|
+
* a wrap is extracted exactly once — testing both would let the second half
|
|
326
|
+
* of a wrapped negative sentence ALSO be misread as an unrelated standalone
|
|
327
|
+
* positive directive.
|
|
328
|
+
*/
|
|
329
|
+
function buildLogicalLines(section) {
|
|
330
|
+
const rawLines = section.text.split('\n').map((l) => l.trim());
|
|
331
|
+
const logical = [];
|
|
332
|
+
let buffer = '';
|
|
333
|
+
for (const line of rawLines) {
|
|
334
|
+
// Headings never end with terminal punctuation, so without this check a
|
|
335
|
+
// "## Heading" line would merge into the body text that follows it,
|
|
336
|
+
// corrupting extractSubjectWordsBefore's backward clause scan (the
|
|
337
|
+
// heading's words bleed into the extracted subject). Headings are still
|
|
338
|
+
// tested as their own logical line — only merging into/out of them is
|
|
339
|
+
// disallowed.
|
|
340
|
+
const isHeading = /^ {0,3}#{1,6}\s/.test(line);
|
|
341
|
+
const isListItem = /^(?:[-*+]\s+|\d+[.)]\s+)/.test(line);
|
|
342
|
+
if (!line || line.startsWith('|') || isHeading) {
|
|
343
|
+
if (buffer) {
|
|
344
|
+
logical.push(buffer);
|
|
345
|
+
buffer = '';
|
|
346
|
+
}
|
|
347
|
+
if (isHeading)
|
|
348
|
+
logical.push(line);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (isListItem && buffer) {
|
|
352
|
+
logical.push(buffer);
|
|
353
|
+
buffer = '';
|
|
354
|
+
}
|
|
355
|
+
buffer = buffer ? `${buffer} ${line}` : line;
|
|
356
|
+
if (/[.!?:;]$/.test(line)) {
|
|
357
|
+
logical.push(buffer);
|
|
358
|
+
buffer = '';
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (buffer)
|
|
362
|
+
logical.push(buffer);
|
|
363
|
+
return logical;
|
|
364
|
+
}
|
|
365
|
+
function execAll(pattern, line) {
|
|
366
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
367
|
+
const re = new RegExp(pattern.source, flags);
|
|
368
|
+
const matches = [];
|
|
369
|
+
let match;
|
|
370
|
+
while ((match = re.exec(line)) !== null) {
|
|
371
|
+
matches.push({ index: match.index, text: match[0] });
|
|
372
|
+
if (match[0].length === 0)
|
|
373
|
+
re.lastIndex++;
|
|
374
|
+
}
|
|
375
|
+
return matches;
|
|
376
|
+
}
|
|
377
|
+
/** Extract all directive matches from a section (over its logical lines). */
|
|
378
|
+
function extractDirectives(section) {
|
|
379
|
+
const results = [];
|
|
380
|
+
const lines = buildLogicalLines({ ...section, text: stripCodeBlocks(section.text) });
|
|
381
|
+
for (let pi = 0; pi < DIRECTIVE_PAIRS.length; pi++) {
|
|
382
|
+
const pair = DIRECTIVE_PAIRS[pi];
|
|
383
|
+
for (const line of lines) {
|
|
384
|
+
const skip = pair.skipLeadingVerb ?? false;
|
|
385
|
+
// extractSubjectBefore applies to both polarities (symmetric copula pairs)
|
|
386
|
+
const beforeBoth = pair.extractSubjectBefore ?? false;
|
|
387
|
+
// Try negative first (more specific — "do not use" is a superset of "use")
|
|
388
|
+
const negMatches = execAll(pair.negativePattern, line);
|
|
389
|
+
// C1: previously this branch `continue`d past the positive check, so a
|
|
390
|
+
// line carrying BOTH polarities ("Use pandas, never use polars.") lost
|
|
391
|
+
// its positive directive entirely. Now both checks always run; to keep
|
|
392
|
+
// the positive check from re-matching text that's actually part of the
|
|
393
|
+
// negative phrase (e.g. the "use" inside "do not use pandas"), the
|
|
394
|
+
// negative match's span is masked out (replaced with spaces, same
|
|
395
|
+
// length so all downstream indices stay valid) before the positive
|
|
396
|
+
// pattern searches.
|
|
397
|
+
let lineForPositiveSearch = line;
|
|
398
|
+
for (const negMatch of negMatches) {
|
|
399
|
+
const anchorEnd = negMatch.index + negMatch.text.length;
|
|
400
|
+
// Negatives always extract AFTER the anchor unless the pair is fully symmetric
|
|
401
|
+
const negBefore = beforeBoth;
|
|
402
|
+
const subject = negBefore
|
|
403
|
+
? normalizeSubject(extractSubjectWordsBefore(line, negMatch.index, 4))
|
|
404
|
+
: normalizeSubject(extractSubjectWords(line, anchorEnd, 3, skip));
|
|
405
|
+
if (subject.length >= 2 && !isGenericSubject(subject)) {
|
|
406
|
+
// BUG-011: detect scoped prohibition ("don't use X for Y")
|
|
407
|
+
const hasQualifier = !negBefore && detectQualifierAfterSubject(line, anchorEnd, subject, skip);
|
|
408
|
+
results.push({ section, pairIndex: pi, polarity: 'negative', subject, matchedLine: line, hasQualifier });
|
|
409
|
+
}
|
|
410
|
+
lineForPositiveSearch =
|
|
411
|
+
lineForPositiveSearch.slice(0, negMatch.index) +
|
|
412
|
+
' '.repeat(negMatch.text.length) +
|
|
413
|
+
lineForPositiveSearch.slice(anchorEnd);
|
|
414
|
+
}
|
|
415
|
+
const posMatches = execAll(pair.positivePattern, lineForPositiveSearch);
|
|
416
|
+
for (const posMatch of posMatches) {
|
|
417
|
+
// BUG-002: extractPositiveSubjectBefore allows copula patterns like "X is acceptable"
|
|
418
|
+
// (positive extracts before) paired with verb negatives like "Never use X" (extracts after).
|
|
419
|
+
const posBefore = beforeBoth || (pair.extractPositiveSubjectBefore ?? false);
|
|
420
|
+
// Subject extraction reads from the ORIGINAL (unmasked) line — safe
|
|
421
|
+
// because the mask only replaces characters with same-length spaces,
|
|
422
|
+
// so posMatch's indices are valid positions in `line` too, and a
|
|
423
|
+
// match can only land outside the masked span in the first place.
|
|
424
|
+
const subject = posBefore
|
|
425
|
+
? normalizeSubject(extractSubjectWordsBefore(line, posMatch.index, 4))
|
|
426
|
+
: normalizeSubject(extractSubjectWords(line, posMatch.index + posMatch.text.length, 3, skip));
|
|
427
|
+
if (subject.length >= 2 && !isGenericSubject(subject)) {
|
|
428
|
+
results.push({ section, pairIndex: pi, polarity: 'positive', subject, matchedLine: line });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return results;
|
|
434
|
+
}
|
|
435
|
+
function conflictId(fileA, startA, fileB, startB, pairIndex, subject) {
|
|
436
|
+
const parts = [`${fileA}:${startA}`, `${fileB}:${startB}`].sort();
|
|
437
|
+
const raw = `${parts[0]}|${parts[1]}|${pairIndex}|${subject}`;
|
|
438
|
+
const hash = crypto.createHash('sha1').update(raw).digest('hex').slice(0, 12);
|
|
439
|
+
return `conflict-${hash}`;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Detect conflicting directives across all instruction files.
|
|
443
|
+
*
|
|
444
|
+
* Confidence:
|
|
445
|
+
* 0.8 — subjects are identical after normalisation
|
|
446
|
+
* 0.5 — subjects share significant word overlap (Jaccard ≥ 0.5)
|
|
447
|
+
*
|
|
448
|
+
* Severity:
|
|
449
|
+
* confidence ≥ 0.7 → "high"
|
|
450
|
+
* confidence < 0.7 → "warning"
|
|
451
|
+
*/
|
|
452
|
+
export function detectConflicts(files) {
|
|
453
|
+
const sections = [];
|
|
454
|
+
for (const file of files) {
|
|
455
|
+
for (const section of file.sections) {
|
|
456
|
+
if (section.text.trim().length >= MIN_SECTION_LENGTH) {
|
|
457
|
+
sections.push(section);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const directives = [];
|
|
462
|
+
for (const section of sections) {
|
|
463
|
+
directives.push(...extractDirectives(section));
|
|
464
|
+
}
|
|
465
|
+
const issues = [];
|
|
466
|
+
const reported = new Set();
|
|
467
|
+
for (let i = 0; i < directives.length; i++) {
|
|
468
|
+
for (let j = i + 1; j < directives.length; j++) {
|
|
469
|
+
const a = directives[i];
|
|
470
|
+
const b = directives[j];
|
|
471
|
+
if (a.pairIndex !== b.pairIndex)
|
|
472
|
+
continue;
|
|
473
|
+
if (a.polarity === b.polarity)
|
|
474
|
+
continue;
|
|
475
|
+
const pos = a.polarity === 'positive' ? a : b;
|
|
476
|
+
const neg = a.polarity === 'negative' ? a : b;
|
|
477
|
+
const sim = pos.subject === neg.subject ? 1 : subjectSimilarity(pos.subject, neg.subject);
|
|
478
|
+
let confidence;
|
|
479
|
+
if (pos.subject === neg.subject) {
|
|
480
|
+
// BUG-011: scoped prohibition ("don't use X for Y") is usually complementary,
|
|
481
|
+
// not contradictory. Lower confidence significantly so it stays below the
|
|
482
|
+
// firing threshold when the negative is qualified.
|
|
483
|
+
//
|
|
484
|
+
// BUG-008: When a single-token subject was extracted from a line that contains a
|
|
485
|
+
// backtick-enclosed method call (e.g. `React.memo()`), the subject was likely
|
|
486
|
+
// truncated at the dot — "react" came from "React.memo", not from the library name
|
|
487
|
+
// being the whole intent. This creates false HIGH-severity conflicts between
|
|
488
|
+
// directives about different React sub-features (React.memo vs React 18 APIs).
|
|
489
|
+
//
|
|
490
|
+
// Only apply the downgrade when BOTH conditions hold:
|
|
491
|
+
// 1. The subject is a single token (e.g. "react")
|
|
492
|
+
// 2. The matched line contains a backtick span whose content starts with that
|
|
493
|
+
// token followed by a dot (evidence that extraction was cut at a method call)
|
|
494
|
+
//
|
|
495
|
+
// This leaves legitimate single-token conflicts (e.g. "jest", "dapper") at full
|
|
496
|
+
// confidence because their lines don't contain "`jest.something`" patterns.
|
|
497
|
+
const subjectTokenCount = pos.subject.split(' ').filter(Boolean).length;
|
|
498
|
+
const truncatedByMethodCall = subjectTokenCount === 1 && (new RegExp(`\`${pos.subject}\\.[a-z]`, 'i').test(pos.matchedLine) ||
|
|
499
|
+
new RegExp(`\`${neg.subject}\\.[a-z]`, 'i').test(neg.matchedLine));
|
|
500
|
+
const pair = DIRECTIVE_PAIRS[a.pairIndex];
|
|
501
|
+
const scopedButPositiveIsAbsoluteAlways = neg.hasQualifier && /\balways\b/i.test(pos.matchedLine) && pair.label.includes('always');
|
|
502
|
+
confidence = scopedButPositiveIsAbsoluteAlways
|
|
503
|
+
? 0.5
|
|
504
|
+
: (neg.hasQualifier ? 0.35 : (truncatedByMethodCall ? 0.5 : 0.8));
|
|
505
|
+
}
|
|
506
|
+
else if (sim >= 0.5) {
|
|
507
|
+
const pair = DIRECTIVE_PAIRS[a.pairIndex];
|
|
508
|
+
const posWords = pos.subject.split(' ').filter(Boolean);
|
|
509
|
+
const negWords = neg.subject.split(' ').filter(Boolean);
|
|
510
|
+
// BUG-010: Require ≥ 2 tokens on both sides for partial Jaccard matches.
|
|
511
|
+
// A 1-word subject (e.g. "react") spuriously collides with "react query"
|
|
512
|
+
// at Jaccard 0.5, conflating unrelated library directives.
|
|
513
|
+
if (posWords.length < 2 || negWords.length < 2) {
|
|
514
|
+
// BUG-001 exception: pairs that explicitly allow single-token Jaccard matches
|
|
515
|
+
// (e.g. "use / migrating away from" where "redux toolkit" vs "redux" is valid).
|
|
516
|
+
// Only allow if the shorter subject is a prefix of the longer one.
|
|
517
|
+
if (!pair.allowSingleTokenMatch)
|
|
518
|
+
continue;
|
|
519
|
+
const shorter = posWords.length <= negWords.length ? posWords[0] : negWords[0];
|
|
520
|
+
const longerSubject = posWords.length > negWords.length ? pos.subject : neg.subject;
|
|
521
|
+
if (!longerSubject.startsWith(shorter))
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
confidence = neg.hasQualifier ? 0.3 : 0.5;
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
// Below actionable threshold — skip
|
|
530
|
+
if (confidence < 0.4)
|
|
531
|
+
continue;
|
|
532
|
+
const id = conflictId(pos.section.filePath, pos.section.startLine, neg.section.filePath, neg.section.startLine, a.pairIndex, pos.subject);
|
|
533
|
+
if (reported.has(id))
|
|
534
|
+
continue;
|
|
535
|
+
reported.add(id);
|
|
536
|
+
const severity = confidence >= 0.7 ? 'high' : 'warning';
|
|
537
|
+
const pair = DIRECTIVE_PAIRS[a.pairIndex];
|
|
538
|
+
const displaySubject = pos.subject.length <= neg.subject.length ? pos.subject : neg.subject;
|
|
539
|
+
const filePaths = Array.from(new Set([pos.section.filePath, neg.section.filePath]));
|
|
540
|
+
issues.push({
|
|
541
|
+
id,
|
|
542
|
+
severity,
|
|
543
|
+
category: 'conflict',
|
|
544
|
+
title: `Possible conflicting instruction: ${displaySubject}`,
|
|
545
|
+
summary: `Possible conflict — review these together. ` +
|
|
546
|
+
`A "${pair.label}" directive pattern was found pointing in opposite directions ` +
|
|
547
|
+
`for the subject "${displaySubject}".`,
|
|
548
|
+
filePaths,
|
|
549
|
+
locations: [
|
|
550
|
+
{
|
|
551
|
+
filePath: pos.section.filePath,
|
|
552
|
+
startLine: pos.section.startLine,
|
|
553
|
+
endLine: pos.section.endLine,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
filePath: neg.section.filePath,
|
|
557
|
+
startLine: neg.section.startLine,
|
|
558
|
+
endLine: neg.section.endLine,
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
evidence: [pos.matchedLine, neg.matchedLine],
|
|
562
|
+
recommendation: 'Review these instructions together and consolidate into one clear directive.',
|
|
563
|
+
confidence,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// ── BUG-001: Cross-pair subject conflict ────────────────────────────────────
|
|
568
|
+
// If the SAME subject appears as a positive directive in one pair (e.g. "prefer
|
|
569
|
+
// default exports") and as a negative directive in a DIFFERENT pair (e.g. "never
|
|
570
|
+
// use default exports"), that is still a conflict — just missed by the pair-index
|
|
571
|
+
// filter above. Fire at warning/confidence 0.5 to stay conservative.
|
|
572
|
+
// Build subject → { positives, negatives } maps across all directives
|
|
573
|
+
const subjectPos = new Map();
|
|
574
|
+
const subjectNeg = new Map();
|
|
575
|
+
for (const d of directives) {
|
|
576
|
+
if (d.polarity === 'positive') {
|
|
577
|
+
if (!subjectPos.has(d.subject))
|
|
578
|
+
subjectPos.set(d.subject, []);
|
|
579
|
+
subjectPos.get(d.subject).push(d);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
if (!subjectNeg.has(d.subject))
|
|
583
|
+
subjectNeg.set(d.subject, []);
|
|
584
|
+
subjectNeg.get(d.subject).push(d);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
for (const [subject, posList] of subjectPos) {
|
|
588
|
+
const negList = subjectNeg.get(subject);
|
|
589
|
+
if (!negList)
|
|
590
|
+
continue;
|
|
591
|
+
for (const pos of posList) {
|
|
592
|
+
for (const neg of negList) {
|
|
593
|
+
// Same pair is already handled — skip
|
|
594
|
+
if (pos.pairIndex === neg.pairIndex)
|
|
595
|
+
continue;
|
|
596
|
+
// Same section — skip
|
|
597
|
+
if (pos.section.filePath === neg.section.filePath &&
|
|
598
|
+
pos.section.startLine === neg.section.startLine)
|
|
599
|
+
continue;
|
|
600
|
+
// BUG-010: enforce ≥ 2-token requirement for cross-pair matches too
|
|
601
|
+
{
|
|
602
|
+
const posWords = pos.subject.split(' ').filter(Boolean).length;
|
|
603
|
+
const negWords = neg.subject.split(' ').filter(Boolean).length;
|
|
604
|
+
if (posWords < 2 || negWords < 2)
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
// BUG-011: scoped negatives are complementary — skip cross-pair too
|
|
608
|
+
if (neg.hasQualifier)
|
|
609
|
+
continue;
|
|
610
|
+
// Use -1 as pairIndex sentinel to distinguish cross-pair IDs from within-pair IDs
|
|
611
|
+
const id = conflictId(pos.section.filePath, pos.section.startLine, neg.section.filePath, neg.section.startLine, -1, subject);
|
|
612
|
+
if (reported.has(id))
|
|
613
|
+
continue;
|
|
614
|
+
reported.add(id);
|
|
615
|
+
const filePaths = Array.from(new Set([pos.section.filePath, neg.section.filePath]));
|
|
616
|
+
issues.push({
|
|
617
|
+
id,
|
|
618
|
+
severity: 'warning',
|
|
619
|
+
category: 'conflict',
|
|
620
|
+
title: `Possible conflicting instruction: ${subject}`,
|
|
621
|
+
summary: `Possible conflict — review these together. ` +
|
|
622
|
+
`Opposing directives for "${subject}" were found across instruction files.`,
|
|
623
|
+
filePaths,
|
|
624
|
+
locations: [
|
|
625
|
+
{ filePath: pos.section.filePath, startLine: pos.section.startLine, endLine: pos.section.endLine },
|
|
626
|
+
{ filePath: neg.section.filePath, startLine: neg.section.startLine, endLine: neg.section.endLine },
|
|
627
|
+
],
|
|
628
|
+
evidence: [pos.matchedLine, neg.matchedLine],
|
|
629
|
+
recommendation: 'Review these instructions together and consolidate into one clear directive.',
|
|
630
|
+
confidence: 0.5,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return issues;
|
|
636
|
+
}
|
|
637
|
+
const VERSION_EXTRACT_PATTERNS = [
|
|
638
|
+
{
|
|
639
|
+
// BUG-002: No leading \b — ".NET" is often preceded by "(" or "—" which are
|
|
640
|
+
// non-word chars, so \b\. would fail to match (.NET 8) or — .NET 8.
|
|
641
|
+
re: /\.NET\s+v?(\d+)(?:\.\d+)*/gi,
|
|
642
|
+
name: '.NET',
|
|
643
|
+
normalize: (v) => v.split('.')[0],
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
re: /ASP\.NET\s+Core\s+v?(\d+)/gi,
|
|
647
|
+
name: 'ASP.NET Core',
|
|
648
|
+
normalize: (v) => v.split('.')[0],
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
re: /\bNode(?:\.?js)?\s+v?(\d+)/gi,
|
|
652
|
+
name: 'Node.js',
|
|
653
|
+
normalize: (v) => v.split('.')[0],
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
re: /\bReact\s+v?(\d+)/gi,
|
|
657
|
+
name: 'React',
|
|
658
|
+
normalize: (v) => v.split('.')[0],
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
re: /\bNext\.?js\s+v?(\d+)/gi,
|
|
662
|
+
name: 'Next.js',
|
|
663
|
+
normalize: (v) => v.split('.')[0],
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
re: /\bPython\s+v?(\d+\.\d+|\d+)/gi,
|
|
667
|
+
name: 'Python',
|
|
668
|
+
normalize: (v) => v.split('.')[0],
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
re: /\bGo\s+v?(\d+\.\d+)/gi,
|
|
672
|
+
name: 'Go',
|
|
673
|
+
normalize: (v) => v, // keep major.minor for Go (1.17 vs 1.18 is significant)
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
re: /\bUnity\s+v?(20\d{2}\.\d+|\d+)/gi,
|
|
677
|
+
name: 'Unity',
|
|
678
|
+
normalize: (v) => v.split('.')[0],
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
re: /\bMirror(?:\s+Networking)?\s+v?(\d{2,4}(?:\.\d+)?)/gi,
|
|
682
|
+
name: 'Mirror',
|
|
683
|
+
normalize: (v) => v.split('.')[0],
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
re: /\bTypeScript\s+v?(\d+)/gi,
|
|
687
|
+
name: 'TypeScript',
|
|
688
|
+
normalize: (v) => v.split('.')[0],
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
re: /\bDjango\s+v?(\d+)/gi,
|
|
692
|
+
name: 'Django',
|
|
693
|
+
normalize: (v) => v.split('.')[0],
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
re: /\bRails?\s+v?(\d+)/gi,
|
|
697
|
+
name: 'Rails',
|
|
698
|
+
normalize: (v) => v.split('.')[0],
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
re: /\bLaravel\s+v?(\d+)/gi,
|
|
702
|
+
name: 'Laravel',
|
|
703
|
+
normalize: (v) => v.split('.')[0],
|
|
704
|
+
},
|
|
705
|
+
// BUG-005: Rust ecosystem — edition (2018/2021/2024) and popular async crates
|
|
706
|
+
{
|
|
707
|
+
// "Rust 2024 edition" / "Rust (2024 edition, MSRV 1.82)" / "edition = '2021'"
|
|
708
|
+
// The \(? handles the parenthesised form: "Rust (2024 edition, ...)"
|
|
709
|
+
re: /\bRust\s*\(?\s*v?(20\d{2})\s+edition\b/gi,
|
|
710
|
+
name: 'Rust edition',
|
|
711
|
+
normalize: (v) => v, // keep full year — 2018 vs 2024 is significant
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
re: /\bedition\s*=\s*["']?(20\d{2})["']?/gi,
|
|
715
|
+
name: 'Rust edition',
|
|
716
|
+
normalize: (v) => v,
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
// tokio 0.2 vs tokio 1.x — major API break between 0.x and 1.x
|
|
720
|
+
// The `? handles backtick-quoted crate names: `tokio` 1.x
|
|
721
|
+
re: /\btokio`?\s+v?(\d+(?:\.\d+)?)/gi,
|
|
722
|
+
name: 'tokio',
|
|
723
|
+
normalize: (v) => {
|
|
724
|
+
const [major, minor] = v.split('.');
|
|
725
|
+
// For 0.x crates, major.minor is significant (0.2 vs 0.3 broke things)
|
|
726
|
+
return major === '0' && minor ? `0.${minor}` : (major ?? v);
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
// axum 0.5 vs axum 0.7 — significant API differences even within 0.x
|
|
731
|
+
// The `? handles backtick-quoted crate names: `axum` 0.7
|
|
732
|
+
re: /\baxum`?\s+v?(\d+(?:\.\d+)?)/gi,
|
|
733
|
+
name: 'axum',
|
|
734
|
+
normalize: (v) => {
|
|
735
|
+
const [major, minor] = v.split('.');
|
|
736
|
+
return major === '0' && minor ? `0.${minor}` : (major ?? v);
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
// BUG-005: Rust MSRV — "MSRV 1.82" / "minimum supported rust version 1.70"
|
|
740
|
+
{
|
|
741
|
+
re: /\b(?:MSRV|minimum\s+supported\s+rust\s+version)\s+v?(\d+\.\d+)/gi,
|
|
742
|
+
name: 'Rust MSRV',
|
|
743
|
+
normalize: (v) => v, // keep full semver — 1.70 vs 1.82 is meaningful
|
|
744
|
+
},
|
|
745
|
+
// BUG-005: Elixir / Phoenix ecosystem
|
|
746
|
+
{
|
|
747
|
+
re: /\bPhoenix\s+v?(\d+(?:\.\d+)?)/gi,
|
|
748
|
+
name: 'Phoenix',
|
|
749
|
+
normalize: (v) => v.split('.')[0],
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
re: /\bElixir\s+v?(\d+(?:\.\d+)?)/gi,
|
|
753
|
+
name: 'Elixir',
|
|
754
|
+
normalize: (v) => v.split('.')[0],
|
|
755
|
+
},
|
|
756
|
+
// BUG-005: JVM ecosystem
|
|
757
|
+
{
|
|
758
|
+
re: /\bSpring\s+Boot\s+v?(\d+)/gi,
|
|
759
|
+
name: 'Spring Boot',
|
|
760
|
+
normalize: (v) => v.split('.')[0],
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
re: /\bJava\s+v?(\d+)/gi,
|
|
764
|
+
name: 'Java',
|
|
765
|
+
normalize: (v) => v.split('.')[0],
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
re: /\bKotlin\s+v?(\d+)/gi,
|
|
769
|
+
name: 'Kotlin',
|
|
770
|
+
normalize: (v) => v.split('.')[0],
|
|
771
|
+
},
|
|
772
|
+
];
|
|
773
|
+
/**
|
|
774
|
+
* Determine whether the match at `index` in `content` is preceded by a negation
|
|
775
|
+
* word or a historical-context phrase within ~50 characters.
|
|
776
|
+
*
|
|
777
|
+
* Exclusions:
|
|
778
|
+
* - "not .NET 8" — explicit negation
|
|
779
|
+
* - "deprecated in Go 1.16" — historical deprecation note, not a version requirement
|
|
780
|
+
* - "removed in Go 1.16" — same
|
|
781
|
+
* - "introduced in Go 1.16" — same
|
|
782
|
+
* - "added in Go 1.16" — same
|
|
783
|
+
*
|
|
784
|
+
* BUG-009 fix: extend the window to 50 chars and add historical-context patterns.
|
|
785
|
+
*/
|
|
786
|
+
function isPrecededByNegation(content, index) {
|
|
787
|
+
const preceding = content.slice(Math.max(0, index - 50), index).toLowerCase();
|
|
788
|
+
return (/\bnot\s+$|\bnot\s*\($|\(not\s/.test(preceding)
|
|
789
|
+
|| preceding.trimEnd().endsWith('not')
|
|
790
|
+
// BUG-009: historical version anchors — "deprecated in X 1.16" is a fact, not a target
|
|
791
|
+
|| /\b(?:deprecated|removed|introduced|added)\s+in\s+\S*\s*$/.test(preceding));
|
|
792
|
+
}
|
|
793
|
+
/** Extract {frameworkName → Set<normalizedVersion>} from a single file's content. */
|
|
794
|
+
function extractVersionDeclarations(content) {
|
|
795
|
+
const result = new Map();
|
|
796
|
+
for (const { re, name, normalize } of VERSION_EXTRACT_PATTERNS) {
|
|
797
|
+
const cloned = new RegExp(re.source, re.flags);
|
|
798
|
+
let m;
|
|
799
|
+
while ((m = cloned.exec(content)) !== null) {
|
|
800
|
+
if (isPrecededByNegation(content, m.index))
|
|
801
|
+
continue;
|
|
802
|
+
const version = normalize(m[1]);
|
|
803
|
+
if (!result.has(name))
|
|
804
|
+
result.set(name, new Set());
|
|
805
|
+
result.get(name).add(version);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* C6: strip fenced code blocks before extracting version declarations.
|
|
812
|
+
* Without this, a fenced example quoting ".NET 6" in one file's
|
|
813
|
+
* documentation reads as a real ".NET 6" declaration and conflicts with
|
|
814
|
+
* genuine ".NET 8" prose elsewhere — the same fence character/length
|
|
815
|
+
* tracking used in scanner.ts, vague-guidance.ts, and stale-instructions.ts.
|
|
816
|
+
*
|
|
817
|
+
* Deliberately does NOT also strip inline `code spans` like those other
|
|
818
|
+
* detectors do: several VERSION_EXTRACT_PATTERNS entries (tokio, axum)
|
|
819
|
+
* explicitly expect a backtick-quoted crate name (`` `tokio` 1.x ``) as a
|
|
820
|
+
* legitimate version declaration, not documentation noise — stripping inline
|
|
821
|
+
* spans would delete the exact text those patterns are designed to match.
|
|
822
|
+
*/
|
|
823
|
+
function stripCodeBlocks(text) {
|
|
824
|
+
const lines = text.split('\n');
|
|
825
|
+
const kept = [];
|
|
826
|
+
let inBlock = false;
|
|
827
|
+
let fenceChar = null;
|
|
828
|
+
let fenceLen = 0;
|
|
829
|
+
for (const line of lines) {
|
|
830
|
+
if (!inBlock) {
|
|
831
|
+
const openMatch = /^ {0,3}([`~]{3,})/.exec(line);
|
|
832
|
+
if (openMatch) {
|
|
833
|
+
inBlock = true;
|
|
834
|
+
fenceChar = openMatch[1][0] ?? null;
|
|
835
|
+
fenceLen = openMatch[1].length;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
kept.push(line);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
const closeMatch = /^ {0,3}([`~]{3,})\s*$/.exec(line);
|
|
842
|
+
if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLen) {
|
|
843
|
+
inBlock = false;
|
|
844
|
+
fenceChar = null;
|
|
845
|
+
fenceLen = 0;
|
|
846
|
+
}
|
|
847
|
+
// else: still inside the fence — drop the line
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return kept.join('\n');
|
|
851
|
+
}
|
|
852
|
+
function versionConflictId(framework, versions) {
|
|
853
|
+
const raw = `version-conflict:${framework}:${[...versions].sort().join('|')}`;
|
|
854
|
+
const hash = crypto.createHash('sha1').update(raw).digest('hex').slice(0, 12);
|
|
855
|
+
return `version-conflict-${hash}`;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Detect when different instruction files specify conflicting major versions of
|
|
859
|
+
* the same framework or runtime (e.g. CLAUDE.md says ".NET 8", AGENTS.md says ".NET 6").
|
|
860
|
+
*
|
|
861
|
+
* Only fires when at least two files carry DIFFERENT positively-stated versions for
|
|
862
|
+
* the same framework — negation references ("not .NET 8") are excluded.
|
|
863
|
+
*
|
|
864
|
+
* Confidence 0.75 → severity "high".
|
|
865
|
+
*/
|
|
866
|
+
export function detectVersionConflicts(files) {
|
|
867
|
+
if (files.length < 2)
|
|
868
|
+
return [];
|
|
869
|
+
// Per-file maps: filePath → (frameworkName → Set<version>)
|
|
870
|
+
const perFile = files.map((f) => ({
|
|
871
|
+
file: f,
|
|
872
|
+
versions: extractVersionDeclarations(stripCodeBlocks(f.content)),
|
|
873
|
+
}));
|
|
874
|
+
// Aggregate: frameworkName → Map<version → filePath[]>
|
|
875
|
+
const aggregate = new Map();
|
|
876
|
+
for (const { file, versions } of perFile) {
|
|
877
|
+
for (const [framework, versionSet] of versions) {
|
|
878
|
+
if (!aggregate.has(framework))
|
|
879
|
+
aggregate.set(framework, new Map());
|
|
880
|
+
const verMap = aggregate.get(framework);
|
|
881
|
+
for (const v of versionSet) {
|
|
882
|
+
if (!verMap.has(v))
|
|
883
|
+
verMap.set(v, []);
|
|
884
|
+
verMap.get(v).push(file.path);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const issues = [];
|
|
889
|
+
const import_path = (p) => p.split(/[\\/]/).pop() ?? p;
|
|
890
|
+
for (const [framework, verMap] of aggregate) {
|
|
891
|
+
if (verMap.size <= 1)
|
|
892
|
+
continue; // all files agree (or only one version found)
|
|
893
|
+
const allVersions = [...verMap.keys()];
|
|
894
|
+
const allFilePaths = [...new Set([...verMap.values()].flat())];
|
|
895
|
+
// Only flag when the conflict spans at least two different files
|
|
896
|
+
const filesPerVersion = allVersions.map((v) => verMap.get(v));
|
|
897
|
+
const allSameFile = filesPerVersion.every((fps) => fps.every((fp) => fp === allFilePaths[0]));
|
|
898
|
+
if (allSameFile)
|
|
899
|
+
continue;
|
|
900
|
+
const id = versionConflictId(framework, allVersions);
|
|
901
|
+
const evidence = allVersions.map((v) => `${framework} ${v} referenced in: ${verMap.get(v).map(import_path).join(', ')}`);
|
|
902
|
+
issues.push({
|
|
903
|
+
id,
|
|
904
|
+
severity: 'high',
|
|
905
|
+
category: 'conflict',
|
|
906
|
+
title: `Version conflict: ${framework} (${allVersions.join(' vs ')})`,
|
|
907
|
+
summary: `Possible conflict — review these together. ` +
|
|
908
|
+
`Different instruction files specify different versions of ${framework}: ` +
|
|
909
|
+
`${allVersions.join(' vs ')}.`,
|
|
910
|
+
filePaths: allFilePaths,
|
|
911
|
+
locations: allFilePaths.map((fp) => ({ filePath: fp })),
|
|
912
|
+
evidence,
|
|
913
|
+
recommendation: `Ensure all instruction files agree on which version of ${framework} this project targets. ` +
|
|
914
|
+
`Remove or update whichever reference is out of date.`,
|
|
915
|
+
confidence: 0.75,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
return issues;
|
|
919
|
+
}
|
|
920
|
+
// ── BUG-B1/B2/B3/B5/B6: Competing-technology conflict detector ───────────────
|
|
921
|
+
function escapeRegex(s) {
|
|
922
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
923
|
+
}
|
|
924
|
+
const COMPETING_TERM_ALIASES = {
|
|
925
|
+
'kebab-case': ['kebab-case', 'kebab case', 'kebabcase'],
|
|
926
|
+
camelcase: ['camelcase', 'camel case', 'camel-case'],
|
|
927
|
+
pascalcase: ['pascalcase', 'pascal case', 'pascal-case'],
|
|
928
|
+
};
|
|
929
|
+
function competingTermRegex(term) {
|
|
930
|
+
const aliases = COMPETING_TERM_ALIASES[term.toLowerCase()] ?? [term];
|
|
931
|
+
return new RegExp(`\\b(?:${aliases.map(escapeRegex).join('|')})\\b`, 'i');
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Mutually-exclusive technology/pattern pairs. When different instruction files
|
|
935
|
+
* recommend competing choices for the same dimension, agents cannot resolve the
|
|
936
|
+
* contradiction on their own.
|
|
937
|
+
*
|
|
938
|
+
* BUG-B1: naming conventions
|
|
939
|
+
* BUG-B2: ORM / data-access / state-management / package manager
|
|
940
|
+
* BUG-B3: Next.js routing strategy
|
|
941
|
+
* BUG-B5: export style
|
|
942
|
+
* BUG-B6: Unity async patterns
|
|
943
|
+
*/
|
|
944
|
+
const COMPETING_PAIRS = [
|
|
945
|
+
// B1 — naming conventions
|
|
946
|
+
{ termA: 'kebab-case', termB: 'camelcase', dimension: 'file naming convention' },
|
|
947
|
+
{ termA: 'kebab-case', termB: 'pascalcase', dimension: 'file naming convention' },
|
|
948
|
+
{ termA: 'camelcase', termB: 'pascalcase', dimension: 'file naming convention' },
|
|
949
|
+
// B2 — ORM / data-access
|
|
950
|
+
{ termA: 'ef core', termB: 'dapper', dimension: 'data access library' },
|
|
951
|
+
// 'Entity Framework Core' / 'Entity Framework' are how instructions often spell it
|
|
952
|
+
{ termA: 'entity framework', termB: 'dapper', dimension: 'data access library' },
|
|
953
|
+
// B2 — state management
|
|
954
|
+
{ termA: 'redux', termB: 'zustand', dimension: 'state management library' },
|
|
955
|
+
{ termA: 'redux', termB: 'mobx', dimension: 'state management library' },
|
|
956
|
+
// B2 — Python package manager
|
|
957
|
+
{ termA: 'pip', termB: 'poetry', dimension: 'Python package manager' },
|
|
958
|
+
// B3 — Next.js routing
|
|
959
|
+
{ termA: 'pages router', termB: 'app router', dimension: 'Next.js routing strategy' },
|
|
960
|
+
// B5 — export style
|
|
961
|
+
{ termA: 'named exports', termB: 'default exports', dimension: 'module export style' },
|
|
962
|
+
// B6 — Unity async patterns (singular and plural — instruction files usually use plural)
|
|
963
|
+
{ termA: 'coroutine', termB: 'unitask', dimension: 'Unity async pattern' },
|
|
964
|
+
{ termA: 'coroutine', termB: 'async/await', dimension: 'Unity async pattern' },
|
|
965
|
+
{ termA: 'coroutines', termB: 'unitask', dimension: 'Unity async pattern' },
|
|
966
|
+
{ termA: 'coroutines', termB: 'async/await', dimension: 'Unity async pattern' },
|
|
967
|
+
// BUG-006: test scope — "unit tests only" vs "full integration suite" are opposing instructions
|
|
968
|
+
{ termA: 'unit tests only', termB: 'integration suite', dimension: 'test scope' },
|
|
969
|
+
{ termA: 'unit tests only', termB: 'full test suite', dimension: 'test scope' },
|
|
970
|
+
{ termA: 'lightweight unit tests', termB: 'integration suite', dimension: 'test scope' },
|
|
971
|
+
// BUG-003: CLI framework pairs
|
|
972
|
+
{ termA: 'cobra', termB: 'urfave', dimension: 'Go CLI framework' },
|
|
973
|
+
// BUG-003: Go ORM/data-access pairs
|
|
974
|
+
{ termA: 'gorm', termB: 'sqlx', dimension: 'Go data-access library' },
|
|
975
|
+
];
|
|
976
|
+
/** Directive verbs signalling a technology is being actively recommended. */
|
|
977
|
+
const TECH_DIRECTIVE_RE = /\b(?:use|prefer|always|must|should|follow|rely\s+on|stick\s+to|require)\b/i;
|
|
978
|
+
/**
|
|
979
|
+
* C5: a directive verb and the tech term must co-occur, but requiring them on
|
|
980
|
+
* the exact SAME physical line missed two common layouts:
|
|
981
|
+
* - a hard line-wrap ("Use\nZustand for all state")
|
|
982
|
+
* - a heading naming the term with the directive on the very next line
|
|
983
|
+
* ("## Zustand\nUse this for all state management.")
|
|
984
|
+
* Checking each line AND each pair of adjacent lines joined by a space covers
|
|
985
|
+
* both without needing full logical-line/paragraph segmentation — this is a
|
|
986
|
+
* boolean co-occurrence check, not subject extraction, so the lower-fidelity
|
|
987
|
+
* join is a safe, low-risk fit.
|
|
988
|
+
*/
|
|
989
|
+
function hasDirectiveNearTerm(content, termRe) {
|
|
990
|
+
const lines = content.split('\n').map((l) => l.trim());
|
|
991
|
+
for (let i = 0; i < lines.length; i++) {
|
|
992
|
+
const line = lines[i];
|
|
993
|
+
if (termRe.test(line) && TECH_DIRECTIVE_RE.test(line))
|
|
994
|
+
return true;
|
|
995
|
+
const next = lines[i + 1];
|
|
996
|
+
if (line && next) {
|
|
997
|
+
const joined = `${line} ${next}`;
|
|
998
|
+
if (termRe.test(joined) && TECH_DIRECTIVE_RE.test(joined))
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Detect conflicts where different instruction files recommend mutually-exclusive
|
|
1006
|
+
* technologies or conventions for the same dimension.
|
|
1007
|
+
*
|
|
1008
|
+
* Fires only when at least one mention is in a directive context (use/prefer/must/…)
|
|
1009
|
+
* to avoid false-positives on casual mentions.
|
|
1010
|
+
*
|
|
1011
|
+
* Confidence: 0.6 (warning).
|
|
1012
|
+
*/
|
|
1013
|
+
export function detectCompetingTechConflicts(files) {
|
|
1014
|
+
if (files.length < 2)
|
|
1015
|
+
return [];
|
|
1016
|
+
const issues = [];
|
|
1017
|
+
const reported = new Set();
|
|
1018
|
+
const strippedContentByPath = new Map(files.map((f) => [f.path, stripCodeBlocks(f.content)]));
|
|
1019
|
+
const searchableContent = (file) => strippedContentByPath.get(file.path) ?? file.content;
|
|
1020
|
+
for (const pair of COMPETING_PAIRS) {
|
|
1021
|
+
const reA = competingTermRegex(pair.termA);
|
|
1022
|
+
const reB = competingTermRegex(pair.termB);
|
|
1023
|
+
const filesWithA = files.filter((f) => reA.test(searchableContent(f)));
|
|
1024
|
+
const filesWithB = files.filter((f) => reB.test(searchableContent(f)));
|
|
1025
|
+
if (filesWithA.length === 0 || filesWithB.length === 0)
|
|
1026
|
+
continue;
|
|
1027
|
+
// Only flag when the terms appear in DIFFERENT files — same-file coexistence is
|
|
1028
|
+
// likely an explicit comparison or migration note, not a real conflict.
|
|
1029
|
+
const pathsA = new Set(filesWithA.map((f) => f.path));
|
|
1030
|
+
const pathsB = new Set(filesWithB.map((f) => f.path));
|
|
1031
|
+
const fullyOverlapping = [...pathsA].every((p) => pathsB.has(p)) && [...pathsB].every((p) => pathsA.has(p));
|
|
1032
|
+
if (fullyOverlapping)
|
|
1033
|
+
continue;
|
|
1034
|
+
// At least one mention must be in a directive context to reduce noise.
|
|
1035
|
+
const hasDirectiveA = filesWithA.some((f) => hasDirectiveNearTerm(searchableContent(f), reA));
|
|
1036
|
+
const hasDirectiveB = filesWithB.some((f) => hasDirectiveNearTerm(searchableContent(f), reB));
|
|
1037
|
+
if (!hasDirectiveA && !hasDirectiveB)
|
|
1038
|
+
continue;
|
|
1039
|
+
const pairKey = [pair.termA.toLowerCase(), pair.termB.toLowerCase()].sort().join('|');
|
|
1040
|
+
if (reported.has(pairKey))
|
|
1041
|
+
continue;
|
|
1042
|
+
reported.add(pairKey);
|
|
1043
|
+
const allFilePaths = [...new Set([...filesWithA, ...filesWithB].map((f) => f.path))];
|
|
1044
|
+
const id = `competing-tech-${crypto.createHash('sha1').update(pairKey).digest('hex').slice(0, 12)}`;
|
|
1045
|
+
issues.push({
|
|
1046
|
+
id,
|
|
1047
|
+
severity: 'warning',
|
|
1048
|
+
category: 'conflict',
|
|
1049
|
+
title: `Possible ${pair.dimension} conflict: "${pair.termA}" vs "${pair.termB}"`,
|
|
1050
|
+
summary: `Possible conflict — review these together. Different instruction files reference ` +
|
|
1051
|
+
`competing ${pair.dimension} choices: "${pair.termA}" and "${pair.termB}".`,
|
|
1052
|
+
filePaths: allFilePaths,
|
|
1053
|
+
locations: allFilePaths.map((fp) => ({ filePath: fp })),
|
|
1054
|
+
evidence: [
|
|
1055
|
+
`"${pair.termA}" referenced in: ${filesWithA.map((f) => path.basename(f.path)).join(', ')}`,
|
|
1056
|
+
`"${pair.termB}" referenced in: ${filesWithB.map((f) => path.basename(f.path)).join(', ')}`,
|
|
1057
|
+
],
|
|
1058
|
+
recommendation: `Review both instruction files and decide which ${pair.dimension} is canonical. ` +
|
|
1059
|
+
`Remove or update whichever reference contradicts the team's actual choice.`,
|
|
1060
|
+
confidence: 0.6,
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
return issues;
|
|
1064
|
+
}
|
|
1065
|
+
//# sourceMappingURL=conflicts.js.map
|