|
3 | 3 | * Tests that the converter correctly handles real-world Claude Code content |
4 | 4 | */ |
5 | 5 | import { describe, expect, test } from 'bun:test' |
6 | | -import fs from 'node:fs' |
7 | | -import path from 'node:path' |
8 | | -import { type ContentType, convertContent } from '../../src/lib/converter.js' |
9 | | -import { parseFrontmatter } from '../../src/lib/frontmatter.js' |
| 6 | +import { convertContent } from '../../src/lib/converter.js' |
10 | 7 |
|
11 | | -const CEP_ROOT = |
12 | | - '/Users/mrbrown/src/github.com/kieranklaassen/compound-engineering-plugin/plugins/compound-engineering' |
13 | | - |
14 | | -interface ValidationResult { |
15 | | - name: string |
16 | | - type: ContentType |
17 | | - original: string |
18 | | - converted: string |
19 | | - issues: string[] |
20 | | - falsePositives: string[] |
21 | | - falseNegatives: string[] |
22 | | - frontmatterChanges: { |
23 | | - removed: string[] |
24 | | - preserved: string[] |
25 | | - } |
26 | | -} |
27 | | - |
28 | | -function detectFalsePositives(_original: string, converted: string): string[] { |
| 8 | +function detectFalsePositives(original: string, converted: string): string[] { |
29 | 9 | const falsePositives: string[] = [] |
30 | | - |
31 | | - // Since Task → task (same word, just lowercased), false-positive detection |
32 | | - // checks that uppercase "Task" used as a noun was NOT incorrectly lowercased. |
33 | | - // The converter's context-dependent regexes should only match tool invocation |
34 | | - // patterns, leaving noun usage with uppercase T untouched. |
35 | 10 | const taskNounLowercased = [ |
36 | 11 | /complete the task\b/g, |
37 | 12 | /\beach task\b/g, |
38 | 13 | /\btask management\b/g, |
39 | 14 | /\btask tracking\b/g, |
40 | 15 | /\btask list\b(?! |$)/g, |
41 | 16 | ] |
42 | | - |
43 | 17 | for (const pattern of taskNounLowercased) { |
44 | | - const matches = converted.match(pattern) |
45 | | - if (matches) { |
46 | | - falsePositives.push( |
47 | | - `False positive: "${matches[0]}" - "Task" as noun incorrectly lowercased`, |
48 | | - ) |
49 | | - } |
50 | | - } |
51 | | - |
52 | | - return falsePositives |
53 | | -} |
54 | | - |
55 | | -function detectFalseNegatives(_original: string, converted: string): string[] { |
56 | | - const falseNegatives: string[] = [] |
57 | | - |
58 | | - const codeBlockPattern = /```[\s\S]*?```|`[^`\n]+`/g |
59 | | - const convertedWithoutCodeBlocks = converted.replace(codeBlockPattern, '') |
60 | | - |
61 | | - const shouldBeConverted = [ |
62 | | - { pattern: /\bTodoWrite\b/g, expected: 'todowrite' }, |
63 | | - { pattern: /\bAskUserQuestion\b/g, expected: 'question' }, |
64 | | - { pattern: /\bWebSearch\b/g, expected: 'google_search' }, |
65 | | - { pattern: /\bWebFetch\b/g, expected: 'webfetch' }, |
66 | | - { pattern: /\.claude\/skills\//g, expected: '.opencode/skills/' }, |
67 | | - { pattern: /\.claude\/commands\//g, expected: '.opencode/commands/' }, |
68 | | - { pattern: /CLAUDE\.md/g, expected: 'AGENTS.md' }, |
69 | | - { pattern: /compound-engineering:/g, expected: 'systematic:' }, |
70 | | - ] |
71 | | - |
72 | | - for (const { pattern, expected } of shouldBeConverted) { |
73 | | - const matches = convertedWithoutCodeBlocks.match(pattern) |
74 | | - if (matches) { |
75 | | - falseNegatives.push( |
76 | | - `False negative: "${matches[0]}" should be converted to "${expected}"`, |
77 | | - ) |
78 | | - } |
79 | | - } |
80 | | - |
81 | | - return falseNegatives |
82 | | -} |
83 | | - |
84 | | -// Helper to analyze frontmatter changes |
85 | | -function analyzeFrontmatter( |
86 | | - original: string, |
87 | | - converted: string, |
88 | | - _type: ContentType, |
89 | | -): { removed: string[]; preserved: string[] } { |
90 | | - const origFm = parseFrontmatter(original) |
91 | | - const convFm = parseFrontmatter(converted) |
92 | | - |
93 | | - const removed: string[] = [] |
94 | | - const preserved: string[] = [] |
95 | | - |
96 | | - const origKeys = Object.keys(origFm.data) |
97 | | - const convKeys = Object.keys(convFm.data) |
98 | | - |
99 | | - for (const key of origKeys) { |
100 | | - if (convKeys.includes(key)) { |
101 | | - preserved.push(key) |
102 | | - } else { |
103 | | - removed.push(key) |
104 | | - } |
105 | | - } |
106 | | - |
107 | | - return { removed, preserved } |
108 | | -} |
109 | | - |
110 | | -// Fields that should be mapped (transformed) during conversion, not just preserved |
111 | | -const MAPPED_AGENT_FIELDS = [ |
112 | | - 'permissionMode', |
113 | | - 'maxTurns', |
114 | | - 'maxSteps', |
115 | | - 'disallowedTools', |
116 | | -] |
117 | | - |
118 | | -function validateFile(filePath: string, type: ContentType): ValidationResult { |
119 | | - const original = fs.readFileSync(filePath, 'utf8') |
120 | | - const converted = convertContent(original, type) |
121 | | - |
122 | | - const issues: string[] = [] |
123 | | - const falsePositives = detectFalsePositives(original, converted) |
124 | | - const falseNegatives = detectFalseNegatives(original, converted) |
125 | | - const frontmatterChanges = analyzeFrontmatter(original, converted, type) |
126 | | - |
127 | | - // Validate required fields preserved |
128 | | - if (frontmatterChanges.removed.includes('name')) { |
129 | | - issues.push('Required field "name" was incorrectly removed') |
130 | | - } |
131 | | - if (frontmatterChanges.removed.includes('description')) { |
132 | | - issues.push('Required field "description" was incorrectly removed') |
133 | | - } |
134 | | - |
135 | | - // Validate mapped fields are consumed (not preserved as-is) for agents |
136 | | - if (type === 'agent') { |
137 | | - for (const field of MAPPED_AGENT_FIELDS) { |
138 | | - if (frontmatterChanges.preserved.includes(field)) { |
139 | | - issues.push( |
140 | | - `Mapped field "${field}" should be consumed during agent conversion`, |
| 18 | + pattern.lastIndex = 0 |
| 19 | + const inConverted = pattern.test(converted) |
| 20 | + pattern.lastIndex = 0 |
| 21 | + const inOriginal = pattern.test(original) |
| 22 | + if (inConverted && !inOriginal) { |
| 23 | + const matches = converted.match(pattern) |
| 24 | + if (matches) { |
| 25 | + falsePositives.push( |
| 26 | + `False positive: "${matches[0]}" - "Task" as noun incorrectly lowercased`, |
141 | 27 | ) |
142 | 28 | } |
143 | 29 | } |
144 | 30 | } |
145 | | - |
146 | | - return { |
147 | | - name: path.basename(filePath), |
148 | | - type, |
149 | | - original, |
150 | | - converted, |
151 | | - issues, |
152 | | - falsePositives, |
153 | | - falseNegatives, |
154 | | - frontmatterChanges, |
155 | | - } |
156 | | -} |
157 | | - |
158 | | -function logValidationIssues(fileName: string, result: ValidationResult): void { |
159 | | - if (result.issues.length > 0) { |
160 | | - console.log(`Issues in ${fileName}:`, result.issues) |
161 | | - } |
162 | | - if (result.falsePositives.length > 0) { |
163 | | - console.log(`False positives in ${fileName}:`, result.falsePositives) |
164 | | - } |
165 | | - if (result.falseNegatives.length > 0) { |
166 | | - console.log(`False negatives in ${fileName}:`, result.falseNegatives) |
167 | | - } |
168 | | -} |
169 | | - |
170 | | -function assertValidConversion(result: ValidationResult): void { |
171 | | - expect(result.issues).toEqual([]) |
172 | | - expect(result.falseNegatives).toEqual([]) |
| 31 | + return falsePositives |
173 | 32 | } |
174 | 33 |
|
175 | | -describe('Converter Validation Against Real CEP Content', () => { |
176 | | - const skipIfNoCEP = () => { |
177 | | - if (!fs.existsSync(CEP_ROOT)) { |
178 | | - console.log('Skipping CEP validation tests - CEP repository not found') |
179 | | - return true |
180 | | - } |
181 | | - return false |
182 | | - } |
183 | | - |
184 | | - const skipIfNoFile = (fullPath: string, fileName: string) => { |
185 | | - if (!fs.existsSync(fullPath)) { |
186 | | - console.log(`Skipping ${fileName} - file not found`) |
187 | | - return true |
188 | | - } |
189 | | - return false |
190 | | - } |
191 | | - |
192 | | - describe('Skills Validation', () => { |
193 | | - const skillFiles = [ |
194 | | - 'agent-browser/SKILL.md', |
195 | | - 'agent-native-architecture/SKILL.md', |
196 | | - 'compound-docs/SKILL.md', |
197 | | - 'file-todos/SKILL.md', |
198 | | - 'brainstorming/SKILL.md', |
199 | | - 'create-agent-skills/SKILL.md', |
200 | | - 'git-worktree/SKILL.md', |
201 | | - 'frontend-design/SKILL.md', |
202 | | - ] |
203 | | - |
204 | | - for (const skillFile of skillFiles) { |
205 | | - const fullPath = path.join(CEP_ROOT, 'skills', skillFile) |
206 | | - |
207 | | - test(`converts ${skillFile} correctly`, () => { |
208 | | - if (skipIfNoCEP()) return |
209 | | - if (skipIfNoFile(fullPath, skillFile)) return |
210 | | - |
211 | | - const result = validateFile(fullPath, 'skill') |
212 | | - logValidationIssues(skillFile, result) |
213 | | - assertValidConversion(result) |
214 | | - }) |
215 | | - } |
216 | | - |
217 | | - test('preserves CC frontmatter fields in skills', () => { |
218 | | - if (skipIfNoCEP()) return |
219 | | - |
220 | | - const fullPath = path.join(CEP_ROOT, 'skills', 'compound-docs/SKILL.md') |
221 | | - if (!fs.existsSync(fullPath)) return |
222 | | - |
223 | | - const result = validateFile(fullPath, 'skill') |
224 | | - |
225 | | - // allowed-tools should now be preserved (non-destructive) |
226 | | - expect(result.frontmatterChanges.preserved).toContain('allowed-tools') |
227 | | - expect(result.frontmatterChanges.preserved).toContain('name') |
228 | | - expect(result.frontmatterChanges.preserved).toContain('description') |
229 | | - }) |
230 | | - }) |
231 | | - |
232 | | - describe('Commands Validation', () => { |
233 | | - const commandFiles = [ |
234 | | - 'workflows/plan.md', |
235 | | - 'workflows/brainstorm.md', |
236 | | - 'lfg.md', |
237 | | - 'deepen-plan.md', |
238 | | - 'heal-skill.md', |
239 | | - ] |
240 | | - |
241 | | - for (const cmdFile of commandFiles) { |
242 | | - const fullPath = path.join(CEP_ROOT, 'commands', cmdFile) |
243 | | - |
244 | | - test(`converts ${cmdFile} correctly`, () => { |
245 | | - if (skipIfNoCEP()) return |
246 | | - if (skipIfNoFile(fullPath, cmdFile)) return |
247 | | - |
248 | | - const result = validateFile(fullPath, 'command') |
249 | | - logValidationIssues(cmdFile, result) |
250 | | - assertValidConversion(result) |
251 | | - }) |
252 | | - } |
253 | | - |
254 | | - test('preserves argument-hint in command frontmatter', () => { |
255 | | - if (skipIfNoCEP()) return |
256 | | - |
257 | | - const fullPath = path.join(CEP_ROOT, 'commands', 'workflows/plan.md') |
258 | | - if (!fs.existsSync(fullPath)) return |
259 | | - |
260 | | - const result = validateFile(fullPath, 'command') |
261 | | - |
262 | | - // argument-hint should now be preserved (non-destructive) |
263 | | - expect(result.frontmatterChanges.preserved).toContain('argument-hint') |
264 | | - }) |
265 | | - }) |
266 | | - |
267 | | - describe('Agents Validation', () => { |
268 | | - const agentFiles = [ |
269 | | - 'review/security-sentinel.md', |
270 | | - 'review/architecture-strategist.md', |
271 | | - 'review/pattern-recognition-specialist.md', |
272 | | - 'research/framework-docs-researcher.md', |
273 | | - ] |
274 | | - |
275 | | - for (const agentFile of agentFiles) { |
276 | | - const fullPath = path.join(CEP_ROOT, 'agents', agentFile) |
277 | | - |
278 | | - test(`converts ${agentFile} correctly`, () => { |
279 | | - if (skipIfNoCEP()) return |
280 | | - if (skipIfNoFile(fullPath, agentFile)) return |
281 | | - |
282 | | - const result = validateFile(fullPath, 'agent') |
283 | | - logValidationIssues(agentFile, result) |
284 | | - assertValidConversion(result) |
285 | | - }) |
286 | | - } |
287 | | - |
288 | | - test('adds required OpenCode agent fields', () => { |
289 | | - if (skipIfNoCEP()) return |
290 | | - |
291 | | - const fullPath = path.join( |
292 | | - CEP_ROOT, |
293 | | - 'agents', |
294 | | - 'review/security-sentinel.md', |
295 | | - ) |
296 | | - if (!fs.existsSync(fullPath)) return |
297 | | - |
298 | | - const original = fs.readFileSync(fullPath, 'utf8') |
299 | | - const converted = convertContent(original, 'agent') |
300 | | - const fm = parseFrontmatter(converted) |
301 | | - |
302 | | - // OpenCode requires these fields for agents |
303 | | - expect(fm.data).toHaveProperty('mode') |
304 | | - expect(fm.data).toHaveProperty('temperature') |
305 | | - expect(fm.data).toHaveProperty('description') |
306 | | - }) |
307 | | - }) |
308 | | - |
| 34 | +describe('Converter Validation', () => { |
309 | 35 | describe('Tool Name Transformations', () => { |
310 | 36 | test('converts Task tool references correctly', () => { |
311 | 37 | const testCases = [ |
@@ -364,6 +90,13 @@ describe('Converter Validation Against Real CEP Content', () => { |
364 | 90 | }) |
365 | 91 |
|
366 | 92 | describe('Edge Cases', () => { |
| 93 | + test('does not flag lowercase task nouns as false positives', () => { |
| 94 | + const original = 'Use task tracking for ongoing work.' |
| 95 | + const converted = convertContent(original, 'skill') |
| 96 | + |
| 97 | + expect(detectFalsePositives(original, converted)).toEqual([]) |
| 98 | + }) |
| 99 | + |
367 | 100 | test('handles empty content', () => { |
368 | 101 | const converted = convertContent('', 'skill') |
369 | 102 | expect(converted).toBe('') |
|
0 commit comments