diff --git a/src/Concerns/RendersBladeGuidelines.php b/src/Concerns/RendersBladeGuidelines.php index 92e49bf2..b70733c6 100644 --- a/src/Concerns/RendersBladeGuidelines.php +++ b/src/Concerns/RendersBladeGuidelines.php @@ -51,7 +51,11 @@ protected function processBoostSnippets(string $content): string $placeholder = '___BOOST_SNIPPET_'.count($this->storedSnippets).'___'; - $this->storedSnippets[$placeholder] = ''."\n".'```'.$lang."\n".$snippetContent."\n".'```'."\n\n"; + // Render the caption as a Markdown sub-heading so it integrates with the + // document's heading structure. Previously this used "" which + // (a) renders as visible text in some Markdown processors, (b) doesn't + // contribute to TOC/navigation, and (c) is unconventional for skill files. + $this->storedSnippets[$placeholder] = '#### '.$name."\n\n".'```'.$lang."\n".$snippetContent."\n".'```'."\n\n"; return $placeholder; }, $content); diff --git a/src/Install/GuidelineAssist.php b/src/Install/GuidelineAssist.php index c45c4646..593a168d 100644 --- a/src/Install/GuidelineAssist.php +++ b/src/Install/GuidelineAssist.php @@ -194,7 +194,13 @@ public function sailBinaryPath(): string public function appPath(string $path = ''): string { - return ltrim(Str::after(app_path($path), base_path()), DIRECTORY_SEPARATOR); + $relativePath = ltrim(Str::after(app_path($path), base_path()), DIRECTORY_SEPARATOR); + + // Normalize to forward slashes for guideline output. On Windows, the separator + // injected by app_path()/base_path() concatenation leaks in as '\', producing + // mixed-separator paths like "app\Http/Kernel.php" when the caller passed a + // forward-slash sub-path. + return str_replace(DIRECTORY_SEPARATOR, '/', $relativePath); } public function hasSkillsEnabled(): bool diff --git a/src/Install/SkillWriter.php b/src/Install/SkillWriter.php index b8ca7d4f..547ac81a 100644 --- a/src/Install/SkillWriter.php +++ b/src/Install/SkillWriter.php @@ -243,18 +243,23 @@ protected function copyFile(SplFileInfo $file, string $targetDir): bool $replacedTargetFile = substr($targetFile, 0, -10).'.md'; } - return file_put_contents($replacedTargetFile, $content) !== false; + return file_put_contents($replacedTargetFile, $this->ensureTrailingNewline($content)) !== false; } if ($isMarkdownFile) { $content = MarkdownFormatter::format(trim(file_get_contents($file->getRealPath()))); - return file_put_contents($targetFile, $content) !== false; + return file_put_contents($targetFile, $this->ensureTrailingNewline($content)) !== false; } return @copy($file->getRealPath(), $targetFile); } + protected function ensureTrailingNewline(string $content): string + { + return str_ends_with($content, "\n") ? $content : $content."\n"; + } + protected function ensureDirectoryExists(string $path): bool { return is_dir($path) || @mkdir($path, 0755, true); diff --git a/tests/Unit/Concerns/RendersBladeGuidelinesTest.php b/tests/Unit/Concerns/RendersBladeGuidelinesTest.php index 712916f1..57dd2b48 100644 --- a/tests/Unit/Concerns/RendersBladeGuidelinesTest.php +++ b/tests/Unit/Concerns/RendersBladeGuidelinesTest.php @@ -41,7 +41,7 @@ public function getStoredSnippets(): array $snippet = $this->renderer->getStoredSnippets()['___BOOST_SNIPPET_0___']; expect($snippet) - ->toStartWith('') + ->toStartWith('#### Authentication Example') ->toContain('```html') ->toContain('return Auth::user();') ->toContain('```'); @@ -53,7 +53,7 @@ public function getStoredSnippets(): array $this->renderer->processSnippets($content); expect($this->renderer->getStoredSnippets()['___BOOST_SNIPPET_0___']) - ->toStartWith(''); + ->toStartWith('#### Double Quoted'); }); test('boostsnippet uses specified language in fenced code block', function (): void { @@ -174,7 +174,7 @@ public function getStoredSnippets(): array $result = $this->renderer->renderFile($tempFile); expect($result) - ->toContain('') + ->toContain('#### Query') ->toContain('```php') ->toContain('User::all()') ->toContain('```') diff --git a/tests/Unit/Install/GuidelineAssistTest.php b/tests/Unit/Install/GuidelineAssistTest.php index 9b39116e..dd2bdc99 100644 --- a/tests/Unit/Install/GuidelineAssistTest.php +++ b/tests/Unit/Install/GuidelineAssistTest.php @@ -200,7 +200,7 @@ $assist->shouldReceive('discover')->andReturn([]); expect($assist->appPath())->toBe('app'); - expect($assist->appPath('path'.DIRECTORY_SEPARATOR.'to'.DIRECTORY_SEPARATOR.'file.php'))->toBe('app'.DIRECTORY_SEPARATOR.'path'.DIRECTORY_SEPARATOR.'to'.DIRECTORY_SEPARATOR.'file.php'); + expect($assist->appPath('path/to/file.php'))->toBe('app/path/to/file.php'); }); test('appPath returns customized path', function (): void { @@ -211,5 +211,19 @@ app()->useAppPath('src'); expect($assist->appPath())->toBe('src'); - expect($assist->appPath('path'.DIRECTORY_SEPARATOR.'to'.DIRECTORY_SEPARATOR.'file.php'))->toBe('src'.DIRECTORY_SEPARATOR.'path'.DIRECTORY_SEPARATOR.'to'.DIRECTORY_SEPARATOR.'file.php'); + expect($assist->appPath('path/to/file.php'))->toBe('src/path/to/file.php'); })->after(fn () => app()->useAppPath('app')); + +test('appPath always uses forward-slash separators for guideline output', function (): void { + // Guideline templates interpolate display-friendly paths. On Windows, app_path() + // concatenates with DIRECTORY_SEPARATOR ('\'), so without normalization + // appPath('Http/Kernel.php') would return "app\Http/Kernel.php" — mixed and + // visually broken in generated CLAUDE.md files. The output must always use '/'. + $assist = Mockery::mock(GuidelineAssist::class, [$this->roster, $this->config])->makePartial(); + $assist->shouldAllowMockingProtectedMethods(); + $assist->shouldReceive('discover')->andReturn([]); + + expect($assist->appPath('Http/Kernel.php'))->toBe('app/Http/Kernel.php') + ->and($assist->appPath('Console/Commands/'))->toBe('app/Console/Commands/') + ->and($assist->appPath())->not->toContain('\\'); +}); diff --git a/tests/Unit/Install/SkillWriterTest.php b/tests/Unit/Install/SkillWriterTest.php index 849fffff..18364b75 100644 --- a/tests/Unit/Install/SkillWriterTest.php +++ b/tests/Unit/Install/SkillWriterTest.php @@ -1068,3 +1068,38 @@ function cleanupSkillDirectory(string $path): void cleanupSkillDirectory($outsideDir); cleanupSkillDirectory($canonicalSkillPath); }); + +it('writes skill files with a trailing newline', function (): void { + // Regression: previously copyFile() ran trim() on rendered/source content and + // wrote it back without re-appending the trailing newline, producing files that + // ended without "\n" — creating noisy diffs on every subsequent edit. + $sourceDir = sys_get_temp_dir().'/boost-test-skill-'.uniqid(); + mkdir($sourceDir, 0755, true); + + // Source intentionally ends WITHOUT a trailing newline. + file_put_contents($sourceDir.'/SKILL.md', "---\nname: trailing-newline-skill\ndescription: regression test\n---\n# Heading\n\nNo trailing newline at the end of this line."); + + $relativeTarget = '.boost-test-skills-'.uniqid(); + $absoluteTarget = base_path($relativeTarget); + + $agent = Mockery::mock(SupportsSkills::class); + $agent->shouldReceive('skillsPath')->andReturn($relativeTarget); + + $skill = new Skill( + name: 'trailing-newline-skill', + package: 'boost', + path: $sourceDir, + description: 'regression test', + ); + + $writer = new SkillWriter($agent); + $result = $writer->write($skill); + + expect($result)->toBe(SkillWriter::SUCCESS); + + $writtenContent = file_get_contents($absoluteTarget.'/trailing-newline-skill/SKILL.md'); + expect($writtenContent)->toEndWith("\n"); + + cleanupSkillDirectory($absoluteTarget); + cleanupSkillDirectory($sourceDir); +});