|
| 1 | +import type {CollectedInputContext, OutputPluginContext, Project, RulePrompt} from '@/types' |
| 2 | +import type {RelativePath, RootPath} from '@/types/FileSystemTypes' |
| 3 | +import * as fs from 'node:fs' |
| 4 | +import * as os from 'node:os' |
| 5 | +import * as path from 'node:path' |
| 6 | +import * as fc from 'fast-check' |
| 7 | +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' |
| 8 | +import {FilePathKind, NamingCaseKind, PromptKind} from '@/types' |
| 9 | +import {ClaudeCodeCLIOutputPlugin} from './ClaudeCodeCLIOutputPlugin' |
| 10 | + |
| 11 | +function createMockRelativePath(pathStr: string, basePath: string): RelativePath { |
| 12 | + return {pathKind: FilePathKind.Relative, path: pathStr, basePath, getDirectoryName: () => pathStr, getAbsolutePath: () => path.join(basePath, pathStr)} |
| 13 | +} |
| 14 | + |
| 15 | +class TestablePlugin extends ClaudeCodeCLIOutputPlugin { |
| 16 | + private mockHomeDir: string | null = null |
| 17 | + public setMockHomeDir(dir: string | null): void { this.mockHomeDir = dir } |
| 18 | + protected override getHomeDir(): string { return this.mockHomeDir ?? super.getHomeDir() } |
| 19 | + public testBuildRuleFileName(rule: RulePrompt): string { return (this as any).buildRuleFileName(rule) } |
| 20 | + public testBuildRuleContent(rule: RulePrompt): string { return (this as any).buildRuleContent(rule) } |
| 21 | +} |
| 22 | + |
| 23 | +function createMockRulePrompt(opts: {series: string, ruleName: string, globs: readonly string[], scope?: 'global' | 'project', content?: string}): RulePrompt { |
| 24 | + const content = opts.content ?? '# Rule body' |
| 25 | + return {type: PromptKind.Rule, content, length: content.length, filePathKind: FilePathKind.Relative, dir: createMockRelativePath('.', ''), markdownContents: [], yamlFrontMatter: {description: 'ignored', globs: opts.globs}, series: opts.series, ruleName: opts.ruleName, globs: opts.globs, scope: opts.scope ?? 'global'} as RulePrompt |
| 26 | +} |
| 27 | + |
| 28 | +const seriesGen = fc.stringMatching(/^[a-z0-9]{1,5}$/) |
| 29 | +const ruleNameGen = fc.stringMatching(/^[a-z][a-z0-9-]{0,14}$/) |
| 30 | +const globGen = fc.stringMatching(/^[a-z*/.]{1,30}$/).filter(s => s.length > 0) |
| 31 | +const globsGen = fc.array(globGen, {minLength: 1, maxLength: 5}) |
| 32 | +const contentGen = fc.string({minLength: 1, maxLength: 200}).filter(s => s.trim().length > 0) |
| 33 | + |
| 34 | +describe('claudeCodeCLIOutputPlugin property tests', () => { |
| 35 | + let tempDir: string, plugin: TestablePlugin, mockContext: OutputPluginContext |
| 36 | + |
| 37 | + beforeEach(() => { |
| 38 | + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-prop-')) |
| 39 | + plugin = new TestablePlugin() |
| 40 | + plugin.setMockHomeDir(tempDir) |
| 41 | + mockContext = { |
| 42 | + collectedInputContext: { |
| 43 | + workspace: {projects: [], directory: createMockRelativePath('.', tempDir)}, |
| 44 | + globalMemory: {type: PromptKind.GlobalMemory, content: 'mem', filePathKind: FilePathKind.Absolute, dir: createMockRelativePath('.', tempDir), markdownContents: []}, |
| 45 | + fastCommands: [], |
| 46 | + subAgents: [], |
| 47 | + skills: [] |
| 48 | + } as unknown as CollectedInputContext, |
| 49 | + logger: {debug: vi.fn(), trace: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any, |
| 50 | + fs, |
| 51 | + path, |
| 52 | + glob: {} as any |
| 53 | + } |
| 54 | + }, 30000) |
| 55 | + |
| 56 | + afterEach(() => { |
| 57 | + try { fs.rmSync(tempDir, {recursive: true, force: true}) } |
| 58 | + catch {} |
| 59 | + }) |
| 60 | + |
| 61 | + describe('rule file name format', () => { |
| 62 | + it('should always produce rule-{series}-{ruleName}.md', async () => { |
| 63 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, async (series, ruleName) => { |
| 64 | + const rule = createMockRulePrompt({series, ruleName, globs: []}) |
| 65 | + const fileName = plugin.testBuildRuleFileName(rule) |
| 66 | + expect(fileName).toBe(`rule-${series}-${ruleName}.md`) |
| 67 | + expect(fileName).toMatch(/^rule-.[^-\n\r\u2028\u2029]*-.+\.md$/) |
| 68 | + }), {numRuns: 100}) |
| 69 | + }) |
| 70 | + }) |
| 71 | + |
| 72 | + describe('rule content format constraints', () => { |
| 73 | + it('should never contain globs field in frontmatter', async () => { |
| 74 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { |
| 75 | + const rule = createMockRulePrompt({series, ruleName, globs, content}) |
| 76 | + const output = plugin.testBuildRuleContent(rule) |
| 77 | + expect(output).not.toMatch(/^globs:/m) |
| 78 | + }), {numRuns: 100}) |
| 79 | + }) |
| 80 | + |
| 81 | + it('should use paths field when globs are present', async () => { |
| 82 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { |
| 83 | + const rule = createMockRulePrompt({series, ruleName, globs, content}) |
| 84 | + const output = plugin.testBuildRuleContent(rule) |
| 85 | + expect(output).toContain('paths:') |
| 86 | + }), {numRuns: 100}) |
| 87 | + }) |
| 88 | + |
| 89 | + it('should wrap frontmatter in --- delimiters when globs exist', async () => { |
| 90 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { |
| 91 | + const rule = createMockRulePrompt({series, ruleName, globs, content}) |
| 92 | + const output = plugin.testBuildRuleContent(rule) |
| 93 | + const lines = output.split('\n') |
| 94 | + expect(lines[0]).toBe('---') |
| 95 | + expect(lines.indexOf('---', 1)).toBeGreaterThan(0) |
| 96 | + }), {numRuns: 100}) |
| 97 | + }) |
| 98 | + |
| 99 | + it('should have no frontmatter when globs are empty', async () => { |
| 100 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, contentGen, async (series, ruleName, content) => { |
| 101 | + const rule = createMockRulePrompt({series, ruleName, globs: [], content}) |
| 102 | + const output = plugin.testBuildRuleContent(rule) |
| 103 | + expect(output).not.toContain('---') |
| 104 | + expect(output).toBe(content) |
| 105 | + }), {numRuns: 100}) |
| 106 | + }) |
| 107 | + |
| 108 | + it('should preserve rule body content after frontmatter', async () => { |
| 109 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { |
| 110 | + const rule = createMockRulePrompt({series, ruleName, globs, content}) |
| 111 | + const output = plugin.testBuildRuleContent(rule) |
| 112 | + expect(output).toContain(content) |
| 113 | + }), {numRuns: 100}) |
| 114 | + }) |
| 115 | + |
| 116 | + it('should list each glob as a YAML array item under paths', async () => { |
| 117 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { |
| 118 | + const rule = createMockRulePrompt({series, ruleName, globs, content}) |
| 119 | + const output = plugin.testBuildRuleContent(rule) |
| 120 | + for (const g of globs) expect(output).toMatch(new RegExp(`- "${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}"|- ${g.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)) |
| 121 | + }), {numRuns: 100}) |
| 122 | + }) |
| 123 | + }) |
| 124 | + |
| 125 | + describe('write output format verification', () => { |
| 126 | + it('should write global rule files with correct format to ~/.claude/rules/', async () => { |
| 127 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { |
| 128 | + const rule = createMockRulePrompt({series, ruleName, globs, scope: 'global', content}) |
| 129 | + const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, rules: [rule]}} as any |
| 130 | + await plugin.writeGlobalOutputs(ctx) |
| 131 | + const filePath = path.join(tempDir, '.claude', 'rules', `rule-${series}-${ruleName}.md`) |
| 132 | + expect(fs.existsSync(filePath)).toBe(true) |
| 133 | + const written = fs.readFileSync(filePath, 'utf8') |
| 134 | + expect(written).toContain('paths:') |
| 135 | + expect(written).not.toMatch(/^globs:/m) |
| 136 | + expect(written).toContain(content) |
| 137 | + }), {numRuns: 30}) |
| 138 | + }) |
| 139 | + |
| 140 | + it('should write project rule files to {project}/.claude/rules/', async () => { |
| 141 | + await fc.assert(fc.asyncProperty(seriesGen, ruleNameGen, globsGen, contentGen, async (series, ruleName, globs, content) => { |
| 142 | + const mockProject: Project = { |
| 143 | + name: 'proj', |
| 144 | + dirFromWorkspacePath: createMockRelativePath('proj', tempDir), |
| 145 | + rootMemoryPrompt: {type: PromptKind.ProjectRootMemory, content: '', filePathKind: FilePathKind.Root, dir: createMockRelativePath('.', tempDir) as unknown as RootPath, markdownContents: [], length: 0, yamlFrontMatter: {namingCase: NamingCaseKind.KebabCase}}, |
| 146 | + childMemoryPrompts: [], |
| 147 | + sourceFiles: [] |
| 148 | + } |
| 149 | + const rule = createMockRulePrompt({series, ruleName, globs, scope: 'project', content}) |
| 150 | + const ctx = {...mockContext, collectedInputContext: {...mockContext.collectedInputContext, workspace: {...mockContext.collectedInputContext.workspace, projects: [mockProject]}, rules: [rule]}} as any |
| 151 | + await plugin.writeProjectOutputs(ctx) |
| 152 | + const filePath = path.join(tempDir, 'proj', '.claude', 'rules', `rule-${series}-${ruleName}.md`) |
| 153 | + expect(fs.existsSync(filePath)).toBe(true) |
| 154 | + const written = fs.readFileSync(filePath, 'utf8') |
| 155 | + expect(written).toContain('paths:') |
| 156 | + expect(written).toContain(content) |
| 157 | + }), {numRuns: 30}) |
| 158 | + }) |
| 159 | + }) |
| 160 | +}) |
0 commit comments