@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.
Files changed (179) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +30 -0
  3. package/dist/agent-practices.d.ts +22 -0
  4. package/dist/agent-practices.d.ts.map +1 -0
  5. package/dist/agent-practices.js +582 -0
  6. package/dist/agent-practices.js.map +1 -0
  7. package/dist/baseline.d.ts +40 -0
  8. package/dist/baseline.d.ts.map +1 -0
  9. package/dist/baseline.js +172 -0
  10. package/dist/baseline.js.map +1 -0
  11. package/dist/canonical-owner.d.ts +3 -0
  12. package/dist/canonical-owner.d.ts.map +1 -0
  13. package/dist/canonical-owner.js +131 -0
  14. package/dist/canonical-owner.js.map +1 -0
  15. package/dist/cheap-model-routing.d.ts +4 -0
  16. package/dist/cheap-model-routing.d.ts.map +1 -0
  17. package/dist/cheap-model-routing.js +43 -0
  18. package/dist/cheap-model-routing.js.map +1 -0
  19. package/dist/ci-alignment.d.ts +10 -0
  20. package/dist/ci-alignment.d.ts.map +1 -0
  21. package/dist/ci-alignment.js +343 -0
  22. package/dist/ci-alignment.js.map +1 -0
  23. package/dist/cli-output-filter.d.ts +16 -0
  24. package/dist/cli-output-filter.d.ts.map +1 -0
  25. package/dist/cli-output-filter.js +88 -0
  26. package/dist/cli-output-filter.js.map +1 -0
  27. package/dist/command-validity.d.ts +12 -0
  28. package/dist/command-validity.d.ts.map +1 -0
  29. package/dist/command-validity.js +543 -0
  30. package/dist/command-validity.js.map +1 -0
  31. package/dist/conflicts.d.ts +49 -0
  32. package/dist/conflicts.d.ts.map +1 -0
  33. package/dist/conflicts.js +1065 -0
  34. package/dist/conflicts.js.map +1 -0
  35. package/dist/context-analysis.d.ts +15 -0
  36. package/dist/context-analysis.d.ts.map +1 -0
  37. package/dist/context-analysis.js +24 -0
  38. package/dist/context-analysis.js.map +1 -0
  39. package/dist/context-bloat.d.ts +17 -0
  40. package/dist/context-bloat.d.ts.map +1 -0
  41. package/dist/context-bloat.js +148 -0
  42. package/dist/context-bloat.js.map +1 -0
  43. package/dist/context-cost.d.ts +4 -0
  44. package/dist/context-cost.d.ts.map +1 -0
  45. package/dist/context-cost.js +70 -0
  46. package/dist/context-cost.js.map +1 -0
  47. package/dist/context-optimizer.d.ts +14 -0
  48. package/dist/context-optimizer.d.ts.map +1 -0
  49. package/dist/context-optimizer.js +219 -0
  50. package/dist/context-optimizer.js.map +1 -0
  51. package/dist/context-recommender.d.ts +3 -0
  52. package/dist/context-recommender.d.ts.map +1 -0
  53. package/dist/context-recommender.js +329 -0
  54. package/dist/context-recommender.js.map +1 -0
  55. package/dist/dead-references.d.ts +33 -0
  56. package/dist/dead-references.d.ts.map +1 -0
  57. package/dist/dead-references.js +607 -0
  58. package/dist/dead-references.js.map +1 -0
  59. package/dist/detectors.d.ts +11 -0
  60. package/dist/detectors.d.ts.map +1 -0
  61. package/dist/detectors.js +160 -0
  62. package/dist/detectors.js.map +1 -0
  63. package/dist/duplicates.d.ts +36 -0
  64. package/dist/duplicates.d.ts.map +1 -0
  65. package/dist/duplicates.js +436 -0
  66. package/dist/duplicates.js.map +1 -0
  67. package/dist/fix-engine.d.ts +16 -0
  68. package/dist/fix-engine.d.ts.map +1 -0
  69. package/dist/fix-engine.js +179 -0
  70. package/dist/fix-engine.js.map +1 -0
  71. package/dist/framework-packs.d.ts +8 -0
  72. package/dist/framework-packs.d.ts.map +1 -0
  73. package/dist/framework-packs.js +22 -0
  74. package/dist/framework-packs.js.map +1 -0
  75. package/dist/frameworks/dotnet.d.ts +4 -0
  76. package/dist/frameworks/dotnet.d.ts.map +1 -0
  77. package/dist/frameworks/dotnet.js +77 -0
  78. package/dist/frameworks/dotnet.js.map +1 -0
  79. package/dist/frameworks/go.d.ts +4 -0
  80. package/dist/frameworks/go.d.ts.map +1 -0
  81. package/dist/frameworks/go.js +39 -0
  82. package/dist/frameworks/go.js.map +1 -0
  83. package/dist/frameworks/nextjs.d.ts +4 -0
  84. package/dist/frameworks/nextjs.d.ts.map +1 -0
  85. package/dist/frameworks/nextjs.js +66 -0
  86. package/dist/frameworks/nextjs.js.map +1 -0
  87. package/dist/frameworks/python.d.ts +4 -0
  88. package/dist/frameworks/python.d.ts.map +1 -0
  89. package/dist/frameworks/python.js +66 -0
  90. package/dist/frameworks/python.js.map +1 -0
  91. package/dist/frameworks/rust.d.ts +4 -0
  92. package/dist/frameworks/rust.d.ts.map +1 -0
  93. package/dist/frameworks/rust.js +72 -0
  94. package/dist/frameworks/rust.js.map +1 -0
  95. package/dist/frameworks/unity.d.ts +4 -0
  96. package/dist/frameworks/unity.d.ts.map +1 -0
  97. package/dist/frameworks/unity.js +74 -0
  98. package/dist/frameworks/unity.js.map +1 -0
  99. package/dist/health-score.d.ts +37 -0
  100. package/dist/health-score.d.ts.map +1 -0
  101. package/dist/health-score.js +164 -0
  102. package/dist/health-score.js.map +1 -0
  103. package/dist/index.d.ts +31 -0
  104. package/dist/index.d.ts.map +1 -0
  105. package/dist/index.js +23 -0
  106. package/dist/index.js.map +1 -0
  107. package/dist/llm.d.ts +6 -0
  108. package/dist/llm.d.ts.map +1 -0
  109. package/dist/llm.js +67 -0
  110. package/dist/llm.js.map +1 -0
  111. package/dist/manifest-consistency.d.ts +37 -0
  112. package/dist/manifest-consistency.d.ts.map +1 -0
  113. package/dist/manifest-consistency.js +659 -0
  114. package/dist/manifest-consistency.js.map +1 -0
  115. package/dist/mcp-detector.d.ts +4 -0
  116. package/dist/mcp-detector.d.ts.map +1 -0
  117. package/dist/mcp-detector.js +131 -0
  118. package/dist/mcp-detector.js.map +1 -0
  119. package/dist/missing-context.d.ts +15 -0
  120. package/dist/missing-context.d.ts.map +1 -0
  121. package/dist/missing-context.js +517 -0
  122. package/dist/missing-context.js.map +1 -0
  123. package/dist/native-tool-preference.d.ts +20 -0
  124. package/dist/native-tool-preference.d.ts.map +1 -0
  125. package/dist/native-tool-preference.js +94 -0
  126. package/dist/native-tool-preference.js.map +1 -0
  127. package/dist/output-verbosity.d.ts +3 -0
  128. package/dist/output-verbosity.d.ts.map +1 -0
  129. package/dist/output-verbosity.js +83 -0
  130. package/dist/output-verbosity.js.map +1 -0
  131. package/dist/product-boundary.d.ts +11 -0
  132. package/dist/product-boundary.d.ts.map +1 -0
  133. package/dist/product-boundary.js +301 -0
  134. package/dist/product-boundary.js.map +1 -0
  135. package/dist/project-type.d.ts +15 -0
  136. package/dist/project-type.d.ts.map +1 -0
  137. package/dist/project-type.js +116 -0
  138. package/dist/project-type.js.map +1 -0
  139. package/dist/prompt-cache.d.ts +3 -0
  140. package/dist/prompt-cache.d.ts.map +1 -0
  141. package/dist/prompt-cache.js +151 -0
  142. package/dist/prompt-cache.js.map +1 -0
  143. package/dist/repo-context.d.ts +37 -0
  144. package/dist/repo-context.d.ts.map +1 -0
  145. package/dist/repo-context.js +221 -0
  146. package/dist/repo-context.js.map +1 -0
  147. package/dist/report.d.ts +87 -0
  148. package/dist/report.d.ts.map +1 -0
  149. package/dist/report.js +558 -0
  150. package/dist/report.js.map +1 -0
  151. package/dist/scan.d.ts +7 -0
  152. package/dist/scan.d.ts.map +1 -0
  153. package/dist/scan.js +43 -0
  154. package/dist/scan.js.map +1 -0
  155. package/dist/scanner.d.ts +3 -0
  156. package/dist/scanner.d.ts.map +1 -0
  157. package/dist/scanner.js +216 -0
  158. package/dist/scanner.js.map +1 -0
  159. package/dist/security-pack.d.ts +4 -0
  160. package/dist/security-pack.d.ts.map +1 -0
  161. package/dist/security-pack.js +403 -0
  162. package/dist/security-pack.js.map +1 -0
  163. package/dist/stale-instructions.d.ts +20 -0
  164. package/dist/stale-instructions.d.ts.map +1 -0
  165. package/dist/stale-instructions.js +269 -0
  166. package/dist/stale-instructions.js.map +1 -0
  167. package/dist/suppression.d.ts +64 -0
  168. package/dist/suppression.d.ts.map +1 -0
  169. package/dist/suppression.js +338 -0
  170. package/dist/suppression.js.map +1 -0
  171. package/dist/types.d.ts +128 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +3 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/vague-guidance.d.ts +12 -0
  176. package/dist/vague-guidance.d.ts.map +1 -0
  177. package/dist/vague-guidance.js +235 -0
  178. package/dist/vague-guidance.js.map +1 -0
  179. 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