diff --git a/.changelog/development-skills-dash.md b/.changelog/development-skills-dash.md new file mode 100644 index 0000000..8eaebd1 --- /dev/null +++ b/.changelog/development-skills-dash.md @@ -0,0 +1,9 @@ +--- +category: added +type: minor +pull_request: 33 +--- + +Add development-only LLM Skills configuration for the bundled Code Standards skill, trusted Fast Forward Composer donors, Changelog and Dash artwork instructions. Keep documentation-authoring skills outside the synchronized sources. Introduce contextual Dash artwork for the repository README. + +Centralize distribution exclusions in Git export-ignore attributes, preserving runtime configuration, the canonical consumer skill and the README artwork. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..57fc751 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +* text=auto +*.php text eol=lf +/bin/code-standards text eol=lf + +# Local state and synchronized copies never belong to package distributions. +/.git export-ignore +/vendor/ export-ignore +/.agents/ export-ignore +/.build/ export-ignore +/.phpunit.cache/ export-ignore +/packaging-consumer/ export-ignore + +# Contributor-only configuration and test infrastructure. +/tests/ export-ignore +/.github/workflows/ export-ignore +/.github/ReleaseChannelUpdater.php export-ignore +/.github/update-release-channels.php export-ignore +/.github/update-release-channels.md export-ignore +/AGENTS.md export-ignore +/skills.json export-ignore +/phpunit.xml export-ignore +/.editorconfig export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/.changelog/ export-ignore + +# Keep src/, bin/, config/, resources/, composer.lock, README and its Dash image. diff --git a/.gitignore b/.gitignore index 7a0cf97..e3f4124 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /vendor/ /.build/ /.phpunit.cache/ + +/.agents/skills/ diff --git a/README.md b/README.md index c3fa7d0..9d343ee 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Fast Forward Code Standards +![Dash, the Fast Forward fox, aligning source cards to illustrate consistent code style.](docs/_static/mascot-banner.png) [![PHP](https://img.shields.io/badge/php-%5E8.3-777BB4?logo=php&logoColor=white)](composer.json) [![Composer Package](https://img.shields.io/badge/composer-fast--forward%2Fcode--standards-F28D1A?logo=composer&logoColor=white)](https://packagist.org/packages/fast-forward/code-standards) [![Tests](https://img.shields.io/github/actions/workflow/status/php-fast-forward/code-standards/repository-ci.yml?branch=main&label=tests)](https://github.com/php-fast-forward/code-standards/actions/workflows/repository-ci.yml) diff --git a/composer.json b/composer.json index d76c4a2..777f523 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "symplify/easy-coding-standard": "^13.3.8" }, "require-dev": { + "llm/skills": "^1.15", "phpunit/phpunit": "^12.5", "symfony/filesystem": "^7.4 || ^8.0", "symfony/http-client": "^7.4 || ^8.0" @@ -73,17 +74,6 @@ "bin": [ "bin/code-standards" ], - "archive": { - "exclude": [ - "/vendor", - "/.build", - "/.phpunit.cache", - "/.git", - "/packaging-consumer", - "/.github/ReleaseChannelUpdater.php", - "/.github/update-release-channels.php" - ] - }, "scripts": { "test": "phpunit", "check": [ @@ -96,7 +86,9 @@ }, "config": { "sort-packages": true, - "allow-plugins": false, + "allow-plugins": { + "llm/skills": true + }, "platform": { "php": "8.3.0" } diff --git a/composer.lock b/composer.lock index 8b9e2a6..6ab9d02 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dbded3ef4a3e25aa79065ba546255638", + "content-hash": "2b0c48f79af0917760467ef640b3a630", "packages": [ { "name": "brick/varexporter", @@ -1493,6 +1493,144 @@ } ], "packages-dev": [ + { + "name": "internal/path", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-internal/path.git", + "reference": "e59fc0d530891483b5f47417ccfecb3d02b2218c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-internal/path/zipball/e59fc0d530891483b5f47417ccfecb3d02b2218c", + "reference": "e59fc0d530891483b5f47417ccfecb3d02b2218c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "buggregator/trap": "^1.15", + "infection/infection": "^0.33.2", + "llm/skills": "^1.7", + "roxblnfk/unpoly": "^1.8.1", + "spiral/code-style": "^2.3.1", + "testo/bridge-infection": "^0.1.6", + "testo/testo": "^0.10.21", + "vimeo/psalm": "^7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Internal\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Aleksei Gagarin", + "homepage": "https://github.com/roxblnfk" + } + ], + "description": "Type-safe, immutable file path library with cross-platform support and automatic normalization.", + "keywords": [ + "cross-platform", + "file", + "filepath", + "filesystem", + "helper", + "path", + "unix", + "windows" + ], + "support": { + "issues": "https://github.com/php-internal/path/issues", + "source": "https://github.com/php-internal/path/tree/1.4.0" + }, + "funding": [ + { + "url": "https://boosty.to/roxblnfk", + "type": "boosty" + } + ], + "time": "2026-09-08T10:51:37+00:00" + }, + { + "name": "llm/skills", + "version": "1.15.0", + "source": { + "type": "git", + "url": "https://github.com/roxblnfk/skills.git", + "reference": "3ee0a545bf3ab77591fd9c35f0317191d78203c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roxblnfk/skills/zipball/3ee0a545bf3ab77591fd9c35f0317191d78203c5", + "reference": "3ee0a545bf3ab77591fd9c35f0317191d78203c5", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "internal/path": "^1.4", + "php": ">=8.2" + }, + "require-dev": { + "buggregator/trap": "^1.16", + "composer/composer": "^2.10.3", + "infection/infection": "dev-testo-bridge-82 as 0.32.6", + "roxblnfk/unpoly": "^1.8", + "spiral/code-style": "^2.3", + "testo/bridge-infection": "0.1 - 1", + "testo/testo": "0.10 - 1", + "vimeo/psalm": "^7" + }, + "type": "composer-plugin", + "extra": { + "class": "LLM\\Skills\\Composer\\SkillsPlugin" + }, + "autoload": { + "psr-4": { + "LLM\\Skills\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Aleksei Gagarin", + "email": "roxblnfk@gmail.com" + } + ], + "description": "AI skills discovery and management system for LLM agents", + "keywords": [ + "agents", + "ai", + "ai-agents", + "ai-skills", + "claude-code", + "dev", + "llm", + "llm-agents", + "skills" + ], + "support": { + "issues": "https://github.com/roxblnfk/skills/issues", + "source": "https://github.com/roxblnfk/skills" + }, + "funding": [ + { + "url": "https://boosty.to/roxblnfk", + "type": "boosty" + } + ], + "time": "2026-10-15T12:10:05+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.14.0", diff --git a/docs/_static/mascot-banner.png b/docs/_static/mascot-banner.png new file mode 100644 index 0000000..7bb8eb4 Binary files /dev/null and b/docs/_static/mascot-banner.png differ diff --git a/skills.json b/skills.json new file mode 100644 index 0000000..e63b49a --- /dev/null +++ b/skills.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://raw.githubusercontent.com/roxblnfk/skills/1.15.0/resources/skills.schema.json", + "target": ".agents/skills", + "aliases": [], + "discovery": false, + "auto-sync": true, + "vendor-sources": false, + "dependencies": { + "composer": { + "trusted": [ + "fast-forward/*" + ], + "trusted-replace": true + } + }, + "sources": [ + { + "from": "dir", + "path": "resources/skills" + }, + { + "from": "github", + "package": "php-fast-forward/changelog", + "ref": "^0.4.0" + }, + { + "from": "github", + "package": "php-fast-forward/.github", + "ref": "v0.3.0", + "skills": [ + "fast-forward-dash-art" + ] + } + ] +} diff --git a/tests/Distribution/ArchiveTest.php b/tests/Distribution/ArchiveTest.php new file mode 100644 index 0000000..32cdac9 --- /dev/null +++ b/tests/Distribution/ArchiveTest.php @@ -0,0 +1,140 @@ +directory = realpath(sys_get_temp_dir()) . '/standards-archive-' . bin2hex(random_bytes(8)); + mkdir($this->directory); + $root = dirname(__DIR__, 2); + $paths = explode("\0", $this->execute(['git', 'ls-files', '-z'], $root)); + $paths[] = '.gitattributes'; + + foreach (array_unique($paths) as $path) { + if ('' === $path || !is_file($root . '/' . $path)) { + continue; + } + $content = file_get_contents($root . '/' . $path); + self::assertIsString($content); + $this->put($path, $content); + } + $readme = file_get_contents($this->directory . '/README.md'); + self::assertIsString($readme); + $this->put('README.md', str_replace("\n", "\r\n", str_replace("\r\n", "\n", $readme))); + $crlfReadme = file_get_contents($this->directory . '/README.md'); + self::assertIsString($crlfReadme); + self::assertStringContainsString("\r\n", $crlfReadme); + foreach (['vendor/local.php', '.agents/skills/copied/SKILL.md', '.build/local.php', '.phpunit.cache/local.php'] as $path) { + $this->put($path, 'fixture-local-state'); + } + $this->execute(['git', 'init', '-q', '-b', 'main']); + $this->execute(['git', 'config', 'user.name', 'Archive Fixture']); + $this->execute(['git', 'config', 'user.email', 'fixture@example.invalid']); + $this->execute(['git', 'add', '-f', '--all']); + $this->execute(['git', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'fixture']); + self::assertStringNotContainsString("\r\n", $this->execute(['git', 'show', 'HEAD:README.md'])); + $this->materializeCommittedFiles(); + $normalizedReadme = file_get_contents($this->directory . '/README.md'); + self::assertIsString($normalizedReadme); + self::assertStringNotContainsString("\r\n", $normalizedReadme); + } + + /** Removes only the exclusively owned fixture and its generated archives. */ + protected function tearDown(): void + { + (new Filesystem())->remove($this->directory); + } + + /** Both routes honor one export-ignore policy while retaining the runtime, canonical skill and README artwork. */ + public function testGitAndComposerExportTheSamePackageFiles(): void + { + mkdir($this->directory . '/.build/archives', recursive: true); + $this->execute(['git', 'archive', '--format=zip', '--output=.build/archives/git.zip', 'HEAD']); + $this->execute(['composer', 'archive', '--format=zip', '--dir=.build/archives', '--file=composer', '--no-plugins', '--no-scripts', '--no-interaction']); + $git = $this->contents($this->directory . '/.build/archives/git.zip'); + $composer = $this->contents($this->directory . '/.build/archives/composer.zip'); + self::assertSame($git, $composer); + foreach (['bin/code-standards', 'config/ecs.php', 'composer.lock', 'README.md', 'CHANGELOG.md', 'LICENSE', + '.github/actions/check/action.yml', 'resources/workflows/code-standards.yml', + 'resources/skills/fast-forward-code-standards/SKILL.md', 'docs/_static/mascot-banner.png'] as $path) { + self::assertArrayHasKey($path, $git); + } + foreach (array_keys($git) as $path) { + self::assertDoesNotMatchRegularExpression('~^(?:vendor|\.agents|\.build|\.phpunit\.cache|tests|\.github/workflows)/~', $path); + } + self::assertArrayNotHasKey('skills.json', $git); + self::assertArrayNotHasKey('.github/ReleaseChannelUpdater.php', $git); + self::assertArrayNotHasKey('.github/update-release-channels.php', $git); + self::assertArrayNotHasKey('.github/update-release-channels.md', $git); + self::assertArrayNotHasKey('archive', json_decode(file_get_contents($this->directory . '/composer.json'), true, flags: JSON_THROW_ON_ERROR)); + } + + /** Writes one synthetic or copied fixture file without traversing another checkout. */ + private function put(string $path, string $content): void + { + $target = $this->directory . '/' . $path; + if (!is_dir(dirname($target))) { + mkdir(dirname($target), recursive: true); + } + file_put_contents($target, $content); + } + + /** Materializes normalized index bytes after removing only the fixture's copied files. */ + private function materializeCommittedFiles(): void + { + $paths = explode("\0", $this->execute(['git', 'ls-files', '-z'])); + foreach ($paths as $path) { + if ('' === $path) { + continue; + } + unlink($this->directory . '/' . $path); + } + + $this->execute(['git', '-c', 'core.autocrlf=false', '-c', 'core.eol=lf', 'checkout-index', '--all', '--force']); + } + + /** + * Runs native tools with no shell and bounded lifetime; any failed archive is a test failure. + * + * @param list $arguments Actual executable and its separate arguments. + * @param string|null $directory Explicit source root, or the isolated fixture when null. + */ + private function execute(array $arguments, ?string $directory = null): string + { + $process = new Process($arguments, $directory ?? $this->directory, timeout: 60); + $process->mustRun(); + + return $process->getOutput(); + } + + /** @return array Sorted archive-relative file names mapped to SHA-256 bytes. */ + private function contents(string $path): array + { + $archive = new PharData($path); + $prefix = 'phar://' . $archive->getPath() . '/'; + $contents = []; + foreach (new \RecursiveIteratorIterator($archive) as $file) { + if ($file->isDir()) { + continue; + } + $relative = substr($file->getPathname(), strlen($prefix)); + $contents[$relative] = hash('sha256', $file->getContent()); + } + ksort($contents); + + return $contents; + } +}