Skip to content

Commit 5a81409

Browse files
marcusrbrownfro-bot[bot]
andauthored
fix: wrap command templates and stabilize integration tests (#78)
* wrap command templates and stabilize integration tests * test: remove CEP-rooted converter integration checks * ci(fro-bot): change `cancel-in-progress` to false * fix: reset regex lastIndex in false positive detector The detectFalsePositives function was incorrectly flagging lowercase task nouns as false positives due to regex state persistence. The .test() method with 'g' flag maintains lastIndex between calls, causing the second test (on original string) to start from the wrong position and return false. Fix: Reset lastIndex=0 before each .test() call to ensure consistent behavior across original and converted strings. --------- Co-authored-by: fro-bot[bot] <agent@fro.bot>
1 parent 5a0975c commit 5a81409

4 files changed

Lines changed: 42 additions & 297 deletions

File tree

.github/workflows/fro-bot.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ permissions:
2121
contents: read
2222

2323
concurrency:
24-
group: fro-bot-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
25-
cancel-in-progress: true
24+
group: fro-bot-${{ github.event.number || github.ref }}
25+
cancel-in-progress: false
2626

2727
env:
2828
DEFAULT_PROMPT: |

src/lib/config-handler.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,16 @@ function loadCommandAsConfig(commandInfo: {
103103

104104
const baseDescription = description || `${name || cleanName} command`
105105

106+
const wrappedTemplate = `<command-instruction>
107+
${body.trim()}
108+
</command-instruction>
109+
110+
<user-request>
111+
$ARGUMENTS
112+
</user-request>`
113+
106114
const config: CommandConfig = {
107-
template: body.trim(),
115+
template: wrappedTemplate,
108116
description: `(Systematic) ${baseDescription}`,
109117
}
110118

tests/integration/converter-validation.test.ts

Lines changed: 20 additions & 287 deletions
Original file line numberDiff line numberDiff line change
@@ -3,309 +3,35 @@
33
* Tests that the converter correctly handles real-world Claude Code content
44
*/
55
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'
107

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[] {
299
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.
3510
const taskNounLowercased = [
3611
/complete the task\b/g,
3712
/\beach task\b/g,
3813
/\btask management\b/g,
3914
/\btask tracking\b/g,
4015
/\btask list\b(?! |$)/g,
4116
]
42-
4317
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`,
14127
)
14228
}
14329
}
14430
}
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
17332
}
17433

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', () => {
30935
describe('Tool Name Transformations', () => {
31036
test('converts Task tool references correctly', () => {
31137
const testCases = [
@@ -364,6 +90,13 @@ describe('Converter Validation Against Real CEP Content', () => {
36490
})
36591

36692
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+
367100
test('handles empty content', () => {
368101
const converted = convertContent('', 'skill')
369102
expect(converted).toBe('')

tests/integration/opencode.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const OPENCODE_AVAILABLE = (() => {
1313
const TIMEOUT_MS = 120_000
1414
const MAX_RETRIES = 2
1515
const RETRY_DELAY_MS = 3_000
16+
const OPENCODE_TEST_MODEL = 'opencode/big-pickle'
1617

1718
const REPO_ROOT = path.resolve(import.meta.dirname, '../..')
1819

@@ -61,14 +62,17 @@ describe.skipIf(!OPENCODE_AVAILABLE)('opencode integration', () => {
6162
}
6263

6364
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
64-
const result = Bun.spawnSync(['opencode', 'run', prompt], {
65-
cwd: testEnv.projectDir,
66-
env: {
67-
...process.env,
68-
OPENCODE_CONFIG_CONTENT: buildOpencodeConfig(),
65+
const result = Bun.spawnSync(
66+
['opencode', 'run', '--model', OPENCODE_TEST_MODEL, prompt],
67+
{
68+
cwd: testEnv.projectDir,
69+
env: {
70+
...process.env,
71+
OPENCODE_CONFIG_CONTENT: buildOpencodeConfig(),
72+
},
73+
timeout: TIMEOUT_MS,
6974
},
70-
timeout: TIMEOUT_MS,
71-
})
75+
)
7276

7377
lastResult = {
7478
stdout: result.stdout.toString(),

0 commit comments

Comments
 (0)