diff --git a/composer.json b/composer.json index 49bafaf7..75120950 100644 --- a/composer.json +++ b/composer.json @@ -99,7 +99,8 @@ ] }, "suggest": { - "latte/latte": "Latte template engine", + "twig/twig": "Twig template engine (recommended for apps, e.g. flightphp/skeleton)", + "latte/latte": "Latte template engine (optional alternative to Twig)", "tracy/tracy": "Tracy debugger", "phpstan/phpstan": "PHP Static Analyzer" }, diff --git a/flight/Engine.php b/flight/Engine.php index 82851f5d..db00d5b1 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -463,8 +463,7 @@ protected function processMiddleware(Route $route, string $eventName): bool } throw new Exception( - "Middleware class '$middleware' not found. " - . "Is it being correctly autoloaded with Flight::path()?" + "Middleware class '$middleware' not found. Is it being correctly autoloaded with Flight::path()?" ); } diff --git a/flight/commands/AiGenerateInstructionsCommand.php b/flight/commands/AiGenerateInstructionsCommand.php index 7cd9aa18..6448e982 100644 --- a/flight/commands/AiGenerateInstructionsCommand.php +++ b/flight/commands/AiGenerateInstructionsCommand.php @@ -42,20 +42,7 @@ public function __construct(array $config) public function execute(): int { $io = $this->app()->io(); - - if (empty($this->config['runway'])) { - $configFile = $this->configFile; - $io = $this->app()->io(); - - $io->warn( - 'The --config-file option is deprecated. ' - . 'Move your config values to the \'runway\' key in the config.php file for configuration.', - true - ); - $runwayConfig = json_decode(file_get_contents($configFile), true) ?? []; - } else { - $runwayConfig = $this->config['runway']; - } + $runwayConfig = $this->resolveRunwayConfig($io); // Check for runway creds ai if (empty($runwayConfig['ai'])) { @@ -64,8 +51,79 @@ public function execute(): int } $io->info('Let\'s gather some project details to generate AI coding instructions.', true); + $userDetails = $this->gatherProjectDetails($io); + $prompt = $this->buildPrompt($userDetails, $this->loadExistingInstructions()); + + // Read LLM creds + $creds = $runwayConfig['ai']; + $headers = [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $creds['api_key'], + ]; + $data = [ + 'model' => $creds['model'], + 'messages' => [ + [ + 'role' => 'system', + // phpcs:ignore Generic.Files.LineLength + 'content' => 'You are a helpful AI coding assistant focused on the Flight Framework for PHP. You are up to date with all your knowledge from https://docs.flightphp.com. As an expert into the programming language PHP, you are top notch at architecting out proper instructions for FlightPHP projects. Output a single AGENTS.md document only.', + ], + ['role' => 'user', 'content' => $prompt], + ], + 'temperature' => 0.2, + ]; + $jsonData = json_encode($data); + + // add info line that this may take a few minutes + $io->info('Generating AI instructions, this may take a few minutes...', true); - // Ask questions + $result = $this->callLlmApi($creds['base_url'], $headers, $jsonData, $io); + if ($result === false) { + return 1; + } + $response = json_decode($result, true); + $instructions = $response['choices'][0]['message']['content'] ?? ''; + if (!$instructions) { + $io->error('No instructions returned from LLM.', true); + return 1; + } + + $agentsPath = $this->projectRoot . 'AGENTS.md'; + $io->info('Updating AGENTS.md...', true); + file_put_contents($agentsPath, $instructions); + $io->ok('AI instructions updated successfully in AGENTS.md.', true); + return 0; + } + + /** + * Resolve runway config from config.php or deprecated --config-file. + * + * @param object $io + * + * @return array + */ + protected function resolveRunwayConfig($io): array + { + if (empty($this->config['runway'])) { + $io->warn( + 'The --config-file option is deprecated. Move your config values to the \'runway\' key in the config.php file for configuration.', // phpcs:ignore + true + ); + return json_decode(file_get_contents($this->configFile), true) ?? []; + } + + return $this->config['runway']; + } + + /** + * Prompt the user for project details used to generate instructions. + * + * @param object $io + * + * @return array + */ + protected function gatherProjectDetails($io): array + { $projectDesc = $io->prompt('Please describe what your project is for?'); $database = $io->prompt( @@ -74,8 +132,8 @@ public function execute(): int ); $templating = $io->prompt( - 'What HTML templating engine will you plan on using (if any)? (recommend latte)', - 'latte' + 'What HTML templating engine will you plan on using (if any)? (recommend twig)', + 'twig' ); $security = $io->confirm('Is security an important element of this project?', 'y'); @@ -95,10 +153,7 @@ public function execute(): int $api = $io->confirm('Will this project expose an API?', 'n'); $other = $io->prompt('Any other important requirements or context? (optional)', 'no'); - // Prepare prompt for LLM - $contextFile = $this->projectRoot . '.github/copilot-instructions.md'; - $context = file_exists($contextFile) === true ? file_get_contents($contextFile) : ''; - $userDetails = [ + return [ 'Project Description' => $projectDesc, 'Database' => $database, 'Templating Engine' => $templating, @@ -110,83 +165,66 @@ public function execute(): int 'API' => $api ? 'yes' : 'no', 'Other' => $other, ]; - $detailsText = ""; + } + + /** + * Build the LLM user prompt from answers and existing instructions. + * + * @param array $userDetails + * @param string $context + * + * @return string + */ + protected function buildPrompt(array $userDetails, string $context): string + { + $detailsText = ''; foreach ($userDetails as $k => $v) { $detailsText .= "$k: $v\n"; } + + // phpcs:disable Generic.Files.LineLength $prompt = << $model, - 'messages' => [ - [ - 'role' => 'system', - 'content' => 'You are a helpful AI coding assistant focused on the Flight Framework for PHP. ' - . 'You are up to date with all your knowledge from https://docs.flightphp.com. ' - . 'As an expert into the programming language PHP, ' - . 'you are top notch at architecting out proper instructions for FlightPHP projects.' - ], - ['role' => 'user', 'content' => $prompt], - ], - 'temperature' => 0.2, - ]; - $jsonData = json_encode($data); +User answers: +$detailsText +Current instructions: +$context +EOT; + // phpcs:enable Generic.Files.LineLength - // add info line that this may take a few minutes - $io->info('Generating AI instructions, this may take a few minutes...', true); + return $prompt; + } - $result = $this->callLlmApi($baseUrl, $headers, $jsonData, $io); - if ($result === false) { - return 1; - } - $response = json_decode($result, true); - $instructions = $response['choices'][0]['message']['content'] ?? ''; - if (!$instructions) { - $io->error('No instructions returned from LLM.', true); - return 1; + /** + * Load existing project instructions for context. + * Prefers AGENTS.md; falls back to legacy .github/copilot-instructions.md. + * + * @return string + */ + protected function loadExistingInstructions(): string + { + $agentsFile = $this->projectRoot . 'AGENTS.md'; + if (file_exists($agentsFile) === true) { + $content = file_get_contents($agentsFile); + return $content !== false ? $content : ''; } - // Write to files - $io->info( - 'Updating .github/copilot-instructions.md, ' - . '.cursor/rules/project-overview.mdc, ' - . '.gemini/GEMINI.md, .windsurfrules and AGENTS.md...', - true - ); - - if (!is_dir($this->projectRoot . '.github')) { - mkdir($this->projectRoot . '.github', 0755, true); - } - if (!is_dir($this->projectRoot . '.cursor/rules')) { - mkdir($this->projectRoot . '.cursor/rules', 0755, true); + $legacyFile = $this->projectRoot . '.github/copilot-instructions.md'; + if (file_exists($legacyFile) === true) { + $content = file_get_contents($legacyFile); + return $content !== false ? $content : ''; } - if (!is_dir($this->projectRoot . '.gemini')) { - mkdir($this->projectRoot . '.gemini', 0755, true); - } - file_put_contents($this->projectRoot . '.github/copilot-instructions.md', $instructions); - file_put_contents($this->projectRoot . '.cursor/rules/project-overview.mdc', $instructions); - file_put_contents($this->projectRoot . '.gemini/GEMINI.md', $instructions); - file_put_contents($this->projectRoot . '.windsurfrules', $instructions); - file_put_contents($this->projectRoot . 'AGENTS.md', $instructions); - $io->ok('AI instructions updated successfully.', true); - return 0; + + return ''; } /** diff --git a/flight/commands/AiInitCommand.php b/flight/commands/AiInitCommand.php index 311dc332..f004332a 100644 --- a/flight/commands/AiInitCommand.php +++ b/flight/commands/AiInitCommand.php @@ -79,7 +79,7 @@ public function execute(): int $defaultModel = 'claude-sonnet-4-5'; break; } - + $model = trim($io->prompt( 'Enter the model name you want to use (e.g. gpt-5, claude-sonnet-4-5, etc)', $defaultModel diff --git a/flight/commands/ControllerCommand.php b/flight/commands/ControllerCommand.php index 4a676bc4..091ba264 100644 --- a/flight/commands/ControllerCommand.php +++ b/flight/commands/ControllerCommand.php @@ -10,6 +10,16 @@ class ControllerCommand extends AbstractBaseCommand { + /** + * Relative directory under app_root for controllers (skeleton: App\Controller). + */ + private const CONTROLLER_DIR = 'Controller'; + + /** + * PSR-4 namespace for generated controllers. + */ + private const CONTROLLER_NAMESPACE = 'App\\Controller'; + /** * Construct * @@ -32,8 +42,7 @@ public function execute(string $controller): void if (empty($this->config['runway'])) { $io->warn( - 'Using a .runway-config.json file is deprecated. ' - . 'Move your config values to app/config/config.php with `php runway config:migrate`.', + 'Using a .runway-config.json file is deprecated. Move your config values to app/config/config.php with `php runway config:migrate`.', // phpcs:ignore true ); // @codeCoverageIgnore @@ -61,7 +70,8 @@ public function execute(string $controller): void $controller .= 'Controller'; } - $controllerPath = $this->projectRoot . '/' . $runwayConfig['app_root'] . 'controllers/' . $controller . '.php'; + $appRoot = rtrim(str_replace('\\', '/', $runwayConfig['app_root']), '/') . '/'; + $controllerPath = $this->projectRoot . '/' . $appRoot . self::CONTROLLER_DIR . '/' . $controller . '.php'; if (file_exists($controllerPath) === true) { $io->error($controller . ' already exists.', true); return; @@ -75,12 +85,12 @@ public function execute(string $controller): void $file = new PhpFile(); $file->setStrictTypes(); - $namespace = new PhpNamespace('app\\controllers'); + $namespace = new PhpNamespace(self::CONTROLLER_NAMESPACE); $namespace->addUse('flight\\Engine'); $class = new ClassType($controller); $class->addProperty('app') - ->setVisibility('protected') + ->setVisibility('private') ->setType('flight\\Engine') ->addComment('@var Engine'); $method = $class->addMethod('__construct') @@ -93,7 +103,7 @@ public function execute(string $controller): void $namespace->add($class); $file->addNamespace($namespace); - $this->persistClass($controller, $file, $runwayConfig['app_root']); + $this->persistClass($controller, $file, $appRoot); $io->ok('Controller successfully created at ' . $controllerPath, true); } @@ -111,7 +121,7 @@ protected function persistClass(string $controllerName, PhpFile $file, string $a { $printer = new \Nette\PhpGenerator\PsrPrinter(); file_put_contents( - $this->projectRoot . '/' . $appRoot . 'controllers/' . $controllerName . '.php', + $this->projectRoot . '/' . $appRoot . self::CONTROLLER_DIR . '/' . $controllerName . '.php', $printer->printFile($file) ); } diff --git a/flight/commands/RouteCommand.php b/flight/commands/RouteCommand.php index b35f03e1..ae81cebe 100644 --- a/flight/commands/RouteCommand.php +++ b/flight/commands/RouteCommand.php @@ -43,8 +43,7 @@ public function execute(): void if (empty($this->config['runway'])) { $io->warn( - 'Using a .runway-config.json file is deprecated. ' - . 'Move your config values to app/config/config.php with `php runway config:migrate`.', + 'Using a .runway-config.json file is deprecated. Move your config values to app/config/config.php with `php runway config:migrate`.', // phpcs:ignore true ); // @codeCoverageIgnore diff --git a/flight/core/Dispatcher.php b/flight/core/Dispatcher.php index 12f33937..62ff7f95 100644 --- a/flight/core/Dispatcher.php +++ b/flight/core/Dispatcher.php @@ -415,8 +415,7 @@ protected function verifyValidClassCallable($class, $method, $resolvedClass): vo // Final check to make sure it's actually a class and a method, or throw an error if (is_object($class) === false && class_exists($class) === false) { $exception = new Exception( - "Class '$class' not found. " - . "Is it being correctly autoloaded with Flight::path()?" + "Class '$class' not found. Is it being correctly autoloaded with Flight::path()?" ); // If this tried to resolve a class in a container and failed somehow, throw the exception diff --git a/flight/core/Loader.php b/flight/core/Loader.php index 36093cb6..92deceb6 100644 --- a/flight/core/Loader.php +++ b/flight/core/Loader.php @@ -47,10 +47,11 @@ class Loader /** * Registers a class. * - * @param string $name Registry name - * @param class-string|(Closure(): T) $class Class name or function to instantiate class - * @param array $params Class initialization parameters - * @param null|(Closure(T $instance): void) $callback $callback Function to call after object instantiation + * @param string $name Registry name + * @param class-string|(Closure(): T) $class Class name or function to instantiate class + * @param array $params Class initialization parameters + * @param null|(Closure(T $instance): void) $callback Function to call after object instantiation + * * @template T of object */ public function register(string $name, $class, array $params = [], ?callable $callback = null): void diff --git a/flight/database/SimplePdo.php b/flight/database/SimplePdo.php index f7015963..4fd98fb7 100644 --- a/flight/database/SimplePdo.php +++ b/flight/database/SimplePdo.php @@ -347,7 +347,8 @@ public function insert(string $table, array $data): string $columnCount = count($columns); foreach ($columns as $col) { - $this->requireSafeIdentifier((string) $col); + $columnName = (string) $col; + $this->requireSafeIdentifier($columnName); } // Validate all rows have same columns @@ -382,7 +383,8 @@ public function insert(string $table, array $data): string $columns = array_keys($data); foreach ($columns as $col) { - $this->requireSafeIdentifier((string) $col); + $columnName = (string) $col; + $this->requireSafeIdentifier($columnName); } $placeholders = array_fill(0, count($data), '?'); @@ -422,8 +424,9 @@ public function update(string $table, array $data, string $where, array $wherePa $sets = []; foreach (array_keys($data) as $column) { - $this->requireSafeIdentifier((string) $column); - $sets[] = "$column = ?"; + $columnName = (string) $column; + $this->requireSafeIdentifier($columnName); + $sets[] = "$columnName = ?"; } $sql = sprintf( diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0db8468e..aab49911 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,7 +1,2 @@ parameters: - ignoreErrors: - - - rawMessage: 'Method flight\core\Dispatcher::parseStringClassAndMethod() should return array{class-string|object, string} but returns non-empty-list.' - identifier: return.type - count: 1 - path: flight/core/Dispatcher.php + ignoreErrors: [] diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index 583e2df5..af8569ec 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -154,8 +154,7 @@ public function testInvalidCallback(): void $this->expectException(Exception::class); $this->expectExceptionMessage( - "Class 'NonExistentClass' not found. " - . "Is it being correctly autoloaded with Flight::path()?" + "Class 'NonExistentClass' not found. Is it being correctly autoloaded with Flight::path()?" ); $this->dispatcher->execute(['NonExistentClass', 'nonExistentMethod']); @@ -285,8 +284,7 @@ public function testExecuteStringClassBadConstructParams(): void $this->expectException(ArgumentCountError::class); $this->expectExceptionMessageMatches( - '#Too few arguments to function tests\\\\classes\\\\TesterClass::__construct\(\), 1 passed' - . ' .+ and exactly 6 expected#' + '#Too few arguments to function tests\\\\classes\\\\TesterClass::__construct\(\), 1 passed .+ and exactly 6 expected#' ); $this->dispatcher->execute(TesterClass::class . '->instanceMethod'); diff --git a/tests/RequestBodyParserTest.php b/tests/RequestBodyParserTest.php index 4f0639cd..21fce6d0 100644 --- a/tests/RequestBodyParserTest.php +++ b/tests/RequestBodyParserTest.php @@ -336,23 +336,15 @@ public function testMultipartParsingEdgeCases(): void $parts[] = "Content-Disposition: form-data; name=\"\"; filename=\"empty.txt\"\r\n\r\nemptyNameValue"; // G: invalid filename triggers sanitized fallback - $parts[] = "Content-Disposition: form-data; " - . "name=\"filebad\"; " - . "filename=\"a*b?.txt\"\r\nContent-Type: text/plain\r\n\r\nFILEBAD"; + $parts[] = "Content-Disposition: form-data; name=\"filebad\"; filename=\"a*b?.txt\"\r\nContent-Type: text/plain\r\n\r\nFILEBAD"; // H1 & H2: two files same key for aggregation logic (arrays) - $parts[] = "Content-Disposition: form-data; " - . "name=\"filemulti\"; " - . "filename=\"one.txt\"\r\nContent-Type: text/plain\r\n\r\nONE"; + $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"one.txt\"\r\nContent-Type: text/plain\r\n\r\nONE"; - $parts[] = "Content-Disposition: form-data; " - . "name=\"filemulti\"; " - . "filename=\"two.txt\"\r\nContent-Type: text/plain\r\n\r\nTWO"; + $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"two.txt\"\r\nContent-Type: text/plain\r\n\r\nTWO"; // I: file exceeding total bytes triggers UPLOAD_ERR_INI_SIZE - $parts[] = "Content-Disposition: form-data; " - . "name=\"filebig\"; " - . "filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\n" + $parts[] = "Content-Disposition: form-data; name=\"filebig\"; filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\n" . str_repeat('A', 10); // Build full body @@ -410,13 +402,9 @@ public function testMultipartEmptyArrayNameStripped(): void // and header param extraction (preg_match_all) $boundary = 'BOUNDARYEMPTY'; - $validFilePart = "Content-Disposition: form-data; " - . "name=\"fileok\"; " - . "filename=\"ok.txt\"\r\nContent-Type: text/plain\r\n\r\nOK"; + $validFilePart = "Content-Disposition: form-data; name=\"fileok\"; filename=\"ok.txt\"\r\nContent-Type: text/plain\r\n\r\nOK"; - $emptyNameFilePart = "Content-Disposition: form-data; " - . "name=\"[]\"; " - . "filename=\"empty.txt\"\r\nContent-Type: text/plain\r\n\r\nSHOULD_SKIP"; + $emptyNameFilePart = "Content-Disposition: form-data; name=\"[]\"; filename=\"empty.txt\"\r\nContent-Type: text/plain\r\n\r\nSHOULD_SKIP"; $body = '--' . $boundary diff --git a/tests/RouterTest.php b/tests/RouterTest.php index cc07a106..9aa4ea3f 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -780,7 +780,7 @@ public function testStripMultipleSlashesFromUrlAndStillMatch(): void $this->request->method = 'GET'; $this->check('OK'); } - + public function testWildcardPassthroughRouteBeforeSpecificGetRoute(): void { $this->router->map('/@par/[^\/]+/*', function (string $par): bool { diff --git a/tests/classes/ContainerDefault.php b/tests/classes/ContainerDefault.php index 68e3ede3..71ad1f55 100644 --- a/tests/classes/ContainerDefault.php +++ b/tests/classes/ContainerDefault.php @@ -33,8 +33,7 @@ public function echoTheContainer(): void public function testUi(): void { - echo 'Route text: ' - . 'The container successfully injected a value into the engine! Engine class: ' + echo 'Route text: The container successfully injected a value into the engine! Engine class: ' . get_class($this->app) . ' test_me_out Value: ' . $this->app->get('test_me_out') diff --git a/tests/commands/AiGenerateInstructionsCommandTest.php b/tests/commands/AiGenerateInstructionsCommandTest.php index 2b16ab8a..a61e7198 100644 --- a/tests/commands/AiGenerateInstructionsCommandTest.php +++ b/tests/commands/AiGenerateInstructionsCommandTest.php @@ -71,6 +71,27 @@ protected function setInput(array $lines): void file_put_contents(self::$in, implode("\n", $lines) . "\n"); } + /** + * Default interactive answers (templating default is twig). + * + * @return array + */ + protected function defaultAnswers(): array + { + return [ + 'desc', + 'mysql', + 'twig', + 'y', + 'y', + 'flight/lib', + 'Docker', + '2', + 'y', + 'context info', + ]; + } + protected function setProjectRoot($command, $path) { $reflection = new \ReflectionClass(get_class($command)); @@ -97,7 +118,7 @@ public function testFailsIfAiConfigMissing() $this->setInput([ 'desc', 'none', - 'latte', + 'twig', 'y', 'y', 'none', @@ -121,26 +142,15 @@ public function testFailsIfAiConfigMissing() $this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou)); } - public function testWritesInstructionsToFiles() + public function testWritesInstructionsToAgentsMdOnly() { $creds = [ 'api_key' => 'key', 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - $this->setInput([ - 'desc', - 'mysql', - 'latte', - 'y', - 'y', - 'flight/lib', - 'Docker', - '2', - 'y', - 'context info' - ]); - $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; + $this->setInput($this->defaultAnswers()); + $mockInstructions = "# Project Instructions\n\nUse MySQL, Twig, Docker."; $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) ->setConstructorArgs([ [ @@ -163,32 +173,117 @@ public function testWritesInstructionsToFiles() 'ai:generate-instructions', ]); $this->assertSame(0, $result); - $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); - $this->assertFileExists($this->baseDir . '.cursor/rules/project-overview.mdc'); - $this->assertFileExists($this->baseDir . '.gemini/GEMINI.md'); - $this->assertFileExists($this->baseDir . '.windsurfrules'); $this->assertFileExists($this->baseDir . 'AGENTS.md'); + $this->assertSame($mockInstructions, file_get_contents($this->baseDir . 'AGENTS.md')); + $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); + $this->assertFileDoesNotExist($this->baseDir . '.cursor/rules/project-overview.mdc'); + $this->assertFileDoesNotExist($this->baseDir . '.gemini/GEMINI.md'); + $this->assertFileDoesNotExist($this->baseDir . '.windsurfrules'); + $this->assertStringContainsString('Updating AGENTS.md', file_get_contents(self::$ou)); } - public function testNoInstructionsReturnedFromLlm() + public function testUsesExistingAgentsMdAsContext() { $creds = [ 'api_key' => 'key', 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - $this->setInput([ - 'desc', - 'mysql', - 'latte', - 'y', - 'y', - 'flight/lib', - 'Docker', - '2', - 'y', - 'context info' + $existing = "# Existing AGENTS\n\nKeep this context."; + file_put_contents($this->baseDir . 'AGENTS.md', $existing); + $this->setInput($this->defaultAnswers()); + + $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) + ->onlyMethods(['callLlmApi']) + ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); + $cmd->expects($this->once()) + ->method('callLlmApi') + ->with( + $this->anything(), + $this->anything(), + $this->callback(function ($jsonData) use ($existing) { + $data = json_decode($jsonData, true); + $userContent = $data['messages'][1]['content'] ?? ''; + return strpos($userContent, $existing) !== false; + }), + $this->anything() + ) + ->willReturn(json_encode([ + 'choices' => [ + ['message' => ['content' => "# Updated\n\nDone."]] + ] + ])); + $app = $this->newApp($cmd); + $result = $app->handle([ + 'runway', + 'ai:generate-instructions', + ]); + $this->assertSame(0, $result); + } + + public function testFallsBackToLegacyCopilotInstructionsForContext() + { + $creds = [ + 'api_key' => 'key', + 'model' => 'gpt-4o', + 'base_url' => 'https://api.openai.com', + ]; + $legacy = "# Legacy copilot instructions\n\nOld layout."; + mkdir($this->baseDir . '.github', 0777, true); + file_put_contents($this->baseDir . '.github/copilot-instructions.md', $legacy); + $this->setInput($this->defaultAnswers()); + + $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) + ->setConstructorArgs([ + [ + 'runway' => ['ai' => $creds] + ] + ]) + ->onlyMethods(['callLlmApi']) + ->getMock(); + $this->setProjectRoot($cmd, $this->baseDir); + $cmd->expects($this->once()) + ->method('callLlmApi') + ->with( + $this->anything(), + $this->anything(), + $this->callback(function ($jsonData) use ($legacy) { + $data = json_decode($jsonData, true); + $userContent = $data['messages'][1]['content'] ?? ''; + return strpos($userContent, $legacy) !== false; + }), + $this->anything() + ) + ->willReturn(json_encode([ + 'choices' => [ + ['message' => ['content' => "# New AGENTS.md\n\nMigrated."]] + ] + ])); + $app = $this->newApp($cmd); + $result = $app->handle([ + 'runway', + 'ai:generate-instructions', ]); + $this->assertSame(0, $result); + $this->assertFileExists($this->baseDir . 'AGENTS.md'); + // Legacy file is left alone; only AGENTS.md is written + $this->assertSame($legacy, file_get_contents($this->baseDir . '.github/copilot-instructions.md')); + } + + public function testNoInstructionsReturnedFromLlm() + { + $creds = [ + 'api_key' => 'key', + 'model' => 'gpt-4o', + 'base_url' => 'https://api.openai.com', + ]; + $this->setInput($this->defaultAnswers()); $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) ->setConstructorArgs([ [ @@ -220,18 +315,7 @@ public function testLlmApiCallFails() 'model' => 'gpt-4o', 'base_url' => 'https://api.openai.com', ]; - $this->setInput([ - 'desc', - 'mysql', - 'latte', - 'y', - 'y', - 'flight/lib', - 'Docker', - '2', - 'y', - 'context info' - ]); + $this->setInput($this->defaultAnswers()); $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) ->setConstructorArgs([ [ @@ -263,19 +347,8 @@ public function testUsesDeprecatedConfigFile() ]; $configFile = $this->baseDir . 'old-config.json'; file_put_contents($configFile, json_encode($creds)); - $this->setInput([ - 'desc', - 'mysql', - 'latte', - 'y', - 'y', - 'flight/lib', - 'Docker', - '2', - 'y', - 'context info' - ]); - $mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker."; + $this->setInput($this->defaultAnswers()); + $mockInstructions = "# Project Instructions\n\nUse MySQL, Twig, Docker."; // runway key is MISSING from config to trigger deprecated logic $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) ->setConstructorArgs([[]]) @@ -297,6 +370,7 @@ public function testUsesDeprecatedConfigFile() ]); $this->assertSame(0, $result); $this->assertStringContainsString('The --config-file option is deprecated', file_get_contents(self::$ou)); - $this->assertFileExists($this->baseDir . '.github/copilot-instructions.md'); + $this->assertFileExists($this->baseDir . 'AGENTS.md'); + $this->assertFileDoesNotExist($this->baseDir . '.github/copilot-instructions.md'); } } diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php index c19b3120..060de908 100644 --- a/tests/commands/ControllerCommandTest.php +++ b/tests/commands/ControllerCommandTest.php @@ -33,12 +33,14 @@ public function tearDown(): void unlink(static::$ou); } - if (file_exists(__DIR__ . '/controllers/TestController.php')) { - unlink(__DIR__ . '/controllers/TestController.php'); + $controllerFile = __DIR__ . '/Controller/TestController.php'; + if (file_exists($controllerFile)) { + unlink($controllerFile); } - if (file_exists(__DIR__ . '/controllers/')) { - rmdir(__DIR__ . '/controllers/'); + $controllerDir = __DIR__ . '/Controller/'; + if (is_dir($controllerDir)) { + rmdir($controllerDir); } // Thanks Windows @@ -65,8 +67,8 @@ public function testConfigAppRootNotSet(): void public function testControllerAlreadyExists(): void { $app = $this->newApp('test', '0.0.1'); - mkdir(__DIR__ . '/controllers/'); - file_put_contents(__DIR__ . '/controllers/TestController.php', 'add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']])); $app->handle(['runway', 'make:controller', 'Test']); @@ -79,6 +81,10 @@ public function testCreateController(): void $app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']])); $app->handle(['runway', 'make:controller', 'Test']); - $this->assertFileExists(__DIR__ . '/controllers/TestController.php'); + $controllerFile = __DIR__ . '/Controller/TestController.php'; + $this->assertFileExists($controllerFile); + $contents = file_get_contents($controllerFile); + $this->assertStringContainsString('namespace App\\Controller;', $contents); + $this->assertStringContainsString('class TestController', $contents); } } diff --git a/tests/server/index.php b/tests/server/index.php index c34822ef..f266e497 100644 --- a/tests/server/index.php +++ b/tests/server/index.php @@ -153,20 +153,20 @@ // Test 14: Overwrite the body with a middleware Flight::route('/overwrite', function () { - echo <<<'html' - Route text: - This route status is that it - failed - html; + echo <<<'HTML' +Route text: +This route status is that it +failed +HTML; })->addMiddleware([new OverwriteBodyMiddleware()]); // Test 15: UTF8 Chars in url Flight::route('/わたしはひとです', function () { - echo <<<'html' - Route text: - This route status is that it - succeeded はい!!! - html; + echo <<<'HTML' +Route text: +This route status is that it +succeeded はい!!! +HTML; }); // Test 16: UTF8 Chars in url with utf8 params