Align Runway generators with skeleton App\ layout and fix suite gates

make:controller now generates App\Controller under app/Controller/, and
ai:generate-instructions writes only AGENTS.md with Twig/SimplePdo
conventions. Clear a stale PHPStan baseline entry and resolve PHPCS
issues so multi-PHP tests/phpstan/phpcs pass cleanly.
pull/714/head
n0nag0n 2 weeks ago
parent f2df5232c4
commit 98c9c89893

@ -99,7 +99,8 @@
] ]
}, },
"suggest": { "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", "tracy/tracy": "Tracy debugger",
"phpstan/phpstan": "PHP Static Analyzer" "phpstan/phpstan": "PHP Static Analyzer"
}, },

@ -463,8 +463,7 @@ class Engine
} }
throw new Exception( throw new Exception(
"Middleware class '$middleware' not found. " "Middleware class '$middleware' not found. Is it being correctly autoloaded with Flight::path()?"
. "Is it being correctly autoloaded with Flight::path()?"
); );
} }

@ -42,20 +42,7 @@ class AiGenerateInstructionsCommand extends AbstractBaseCommand
public function execute(): int public function execute(): int
{ {
$io = $this->app()->io(); $io = $this->app()->io();
$runwayConfig = $this->resolveRunwayConfig($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'];
}
// Check for runway creds ai // Check for runway creds ai
if (empty($runwayConfig['ai'])) { if (empty($runwayConfig['ai'])) {
@ -64,8 +51,79 @@ class AiGenerateInstructionsCommand extends AbstractBaseCommand
} }
$io->info('Let\'s gather some project details to generate AI coding instructions.', true); $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<string,mixed>
*/
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<string,string>
*/
protected function gatherProjectDetails($io): array
{
$projectDesc = $io->prompt('Please describe what your project is for?'); $projectDesc = $io->prompt('Please describe what your project is for?');
$database = $io->prompt( $database = $io->prompt(
@ -74,8 +132,8 @@ class AiGenerateInstructionsCommand extends AbstractBaseCommand
); );
$templating = $io->prompt( $templating = $io->prompt(
'What HTML templating engine will you plan on using (if any)? (recommend latte)', 'What HTML templating engine will you plan on using (if any)? (recommend twig)',
'latte' 'twig'
); );
$security = $io->confirm('Is security an important element of this project?', 'y'); $security = $io->confirm('Is security an important element of this project?', 'y');
@ -95,10 +153,7 @@ class AiGenerateInstructionsCommand extends AbstractBaseCommand
$api = $io->confirm('Will this project expose an API?', 'n'); $api = $io->confirm('Will this project expose an API?', 'n');
$other = $io->prompt('Any other important requirements or context? (optional)', 'no'); $other = $io->prompt('Any other important requirements or context? (optional)', 'no');
// Prepare prompt for LLM return [
$contextFile = $this->projectRoot . '.github/copilot-instructions.md';
$context = file_exists($contextFile) === true ? file_get_contents($contextFile) : '';
$userDetails = [
'Project Description' => $projectDesc, 'Project Description' => $projectDesc,
'Database' => $database, 'Database' => $database,
'Templating Engine' => $templating, 'Templating Engine' => $templating,
@ -110,83 +165,66 @@ class AiGenerateInstructionsCommand extends AbstractBaseCommand
'API' => $api ? 'yes' : 'no', 'API' => $api ? 'yes' : 'no',
'Other' => $other, 'Other' => $other,
]; ];
$detailsText = ""; }
/**
* Build the LLM user prompt from answers and existing instructions.
*
* @param array<string,string> $userDetails
* @param string $context
*
* @return string
*/
protected function buildPrompt(array $userDetails, string $context): string
{
$detailsText = '';
foreach ($userDetails as $k => $v) { foreach ($userDetails as $k => $v) {
$detailsText .= "$k: $v\n"; $detailsText .= "$k: $v\n";
} }
$prompt = <<<EOT
You are an AI coding assistant. Update the following project instructions for this Flight PHP project based on the latest user answers. Only output the new instructions, no extra commentary.
User answers:
$detailsText
Current instructions:
$context
EOT; // phpcs:ignore
// Read LLM creds // phpcs:disable Generic.Files.LineLength
$creds = $runwayConfig['ai']; $prompt = <<<EOT
$apiKey = $creds['api_key']; You are an AI coding assistant. Write or update project instructions for this Flight PHP project based on the latest user answers. Only output the new instructions (markdown suitable for AGENTS.md), no extra commentary.
$model = $creds['model'];
$baseUrl = $creds['base_url'];
// Prepare curl call (OpenAI compatible) Conventions to encode in the instructions (unless the user answers clearly contradict them):
$headers = [ - Use App\\ namespaces: App\\Controller, App\\Middleware, App\\Model, App\\Utils, App\\Command
'Content-Type: application/json', - Controllers live in app/Controller/; inject flight\\Engine and other services via the DI container (Dice). Do not use the Flight:: facade in the app layer.
'Authorization: Bearer ' . $apiKey, - Prefer flight\\database\\SimplePdo for database access (PdoWrapper is deprecated). Use ActiveRecord for models when an ORM is needed.
]; - Prefer Twig for HTML views when a templating engine is used.
$data = [ - AGENTS.md is the sole AI instruction surface (no separate Copilot/Cursor/Gemini/Windsurf rule files). Scoped AGENTS.md files under app/ directories are fine when useful.
'model' => $model, - Keep Flight simple and fast; avoid unnecessary abstractions.
'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);
// add info line that this may take a few minutes User answers:
$io->info('Generating AI instructions, this may take a few minutes...', true); $detailsText
Current instructions:
$context
EOT;
// phpcs:enable Generic.Files.LineLength
$result = $this->callLlmApi($baseUrl, $headers, $jsonData, $io); return $prompt;
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;
} }
// Write to files /**
$io->info( * Load existing project instructions for context.
'Updating .github/copilot-instructions.md, ' * Prefers AGENTS.md; falls back to legacy .github/copilot-instructions.md.
. '.cursor/rules/project-overview.mdc, ' *
. '.gemini/GEMINI.md, .windsurfrules and AGENTS.md...', * @return string
true */
); protected function loadExistingInstructions(): string
{
if (!is_dir($this->projectRoot . '.github')) { $agentsFile = $this->projectRoot . 'AGENTS.md';
mkdir($this->projectRoot . '.github', 0755, true); if (file_exists($agentsFile) === true) {
} $content = file_get_contents($agentsFile);
if (!is_dir($this->projectRoot . '.cursor/rules')) { return $content !== false ? $content : '';
mkdir($this->projectRoot . '.cursor/rules', 0755, true);
} }
if (!is_dir($this->projectRoot . '.gemini')) {
mkdir($this->projectRoot . '.gemini', 0755, true); $legacyFile = $this->projectRoot . '.github/copilot-instructions.md';
if (file_exists($legacyFile) === true) {
$content = file_get_contents($legacyFile);
return $content !== false ? $content : '';
} }
file_put_contents($this->projectRoot . '.github/copilot-instructions.md', $instructions);
file_put_contents($this->projectRoot . '.cursor/rules/project-overview.mdc', $instructions); return '';
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;
} }
/** /**

@ -10,6 +10,16 @@ use Nette\PhpGenerator\PhpNamespace;
class ControllerCommand extends AbstractBaseCommand 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 * Construct
* *
@ -32,8 +42,7 @@ class ControllerCommand extends AbstractBaseCommand
if (empty($this->config['runway'])) { if (empty($this->config['runway'])) {
$io->warn( $io->warn(
'Using a .runway-config.json file is deprecated. ' 'Using a .runway-config.json file is deprecated. Move your config values to app/config/config.php with `php runway config:migrate`.', // phpcs:ignore
. 'Move your config values to app/config/config.php with `php runway config:migrate`.',
true true
); // @codeCoverageIgnore ); // @codeCoverageIgnore
@ -61,7 +70,8 @@ class ControllerCommand extends AbstractBaseCommand
$controller .= 'Controller'; $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) { if (file_exists($controllerPath) === true) {
$io->error($controller . ' already exists.', true); $io->error($controller . ' already exists.', true);
return; return;
@ -75,12 +85,12 @@ class ControllerCommand extends AbstractBaseCommand
$file = new PhpFile(); $file = new PhpFile();
$file->setStrictTypes(); $file->setStrictTypes();
$namespace = new PhpNamespace('app\\controllers'); $namespace = new PhpNamespace(self::CONTROLLER_NAMESPACE);
$namespace->addUse('flight\\Engine'); $namespace->addUse('flight\\Engine');
$class = new ClassType($controller); $class = new ClassType($controller);
$class->addProperty('app') $class->addProperty('app')
->setVisibility('protected') ->setVisibility('private')
->setType('flight\\Engine') ->setType('flight\\Engine')
->addComment('@var Engine'); ->addComment('@var Engine');
$method = $class->addMethod('__construct') $method = $class->addMethod('__construct')
@ -93,7 +103,7 @@ class ControllerCommand extends AbstractBaseCommand
$namespace->add($class); $namespace->add($class);
$file->addNamespace($namespace); $file->addNamespace($namespace);
$this->persistClass($controller, $file, $runwayConfig['app_root']); $this->persistClass($controller, $file, $appRoot);
$io->ok('Controller successfully created at ' . $controllerPath, true); $io->ok('Controller successfully created at ' . $controllerPath, true);
} }
@ -111,7 +121,7 @@ class ControllerCommand extends AbstractBaseCommand
{ {
$printer = new \Nette\PhpGenerator\PsrPrinter(); $printer = new \Nette\PhpGenerator\PsrPrinter();
file_put_contents( file_put_contents(
$this->projectRoot . '/' . $appRoot . 'controllers/' . $controllerName . '.php', $this->projectRoot . '/' . $appRoot . self::CONTROLLER_DIR . '/' . $controllerName . '.php',
$printer->printFile($file) $printer->printFile($file)
); );
} }

@ -43,8 +43,7 @@ class RouteCommand extends AbstractBaseCommand
if (empty($this->config['runway'])) { if (empty($this->config['runway'])) {
$io->warn( $io->warn(
'Using a .runway-config.json file is deprecated. ' 'Using a .runway-config.json file is deprecated. Move your config values to app/config/config.php with `php runway config:migrate`.', // phpcs:ignore
. 'Move your config values to app/config/config.php with `php runway config:migrate`.',
true true
); // @codeCoverageIgnore ); // @codeCoverageIgnore

@ -415,8 +415,7 @@ class Dispatcher
// Final check to make sure it's actually a class and a method, or throw an error // 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) { if (is_object($class) === false && class_exists($class) === false) {
$exception = new Exception( $exception = new Exception(
"Class '$class' not found. " "Class '$class' not found. Is it being correctly autoloaded with Flight::path()?"
. "Is it being correctly autoloaded with Flight::path()?"
); );
// If this tried to resolve a class in a container and failed somehow, throw the exception // If this tried to resolve a class in a container and failed somehow, throw the exception

@ -50,7 +50,8 @@ class Loader
* @param string $name Registry name * @param string $name Registry name
* @param class-string<T>|(Closure(): T) $class Class name or function to instantiate class * @param class-string<T>|(Closure(): T) $class Class name or function to instantiate class
* @param array<int, mixed> $params Class initialization parameters * @param array<int, mixed> $params Class initialization parameters
* @param null|(Closure(T $instance): void) $callback $callback Function to call after object instantiation * @param null|(Closure(T $instance): void) $callback Function to call after object instantiation
*
* @template T of object * @template T of object
*/ */
public function register(string $name, $class, array $params = [], ?callable $callback = null): void public function register(string $name, $class, array $params = [], ?callable $callback = null): void

@ -347,7 +347,8 @@ class SimplePdo extends PdoWrapper
$columnCount = count($columns); $columnCount = count($columns);
foreach ($columns as $col) { foreach ($columns as $col) {
$this->requireSafeIdentifier((string) $col); $columnName = (string) $col;
$this->requireSafeIdentifier($columnName);
} }
// Validate all rows have same columns // Validate all rows have same columns
@ -382,7 +383,8 @@ class SimplePdo extends PdoWrapper
$columns = array_keys($data); $columns = array_keys($data);
foreach ($columns as $col) { foreach ($columns as $col) {
$this->requireSafeIdentifier((string) $col); $columnName = (string) $col;
$this->requireSafeIdentifier($columnName);
} }
$placeholders = array_fill(0, count($data), '?'); $placeholders = array_fill(0, count($data), '?');
@ -422,8 +424,9 @@ class SimplePdo extends PdoWrapper
$sets = []; $sets = [];
foreach (array_keys($data) as $column) { foreach (array_keys($data) as $column) {
$this->requireSafeIdentifier((string) $column); $columnName = (string) $column;
$sets[] = "$column = ?"; $this->requireSafeIdentifier($columnName);
$sets[] = "$columnName = ?";
} }
$sql = sprintf( $sql = sprintf(

@ -1,7 +1,2 @@
parameters: parameters:
ignoreErrors: ignoreErrors: []
-
rawMessage: 'Method flight\core\Dispatcher::parseStringClassAndMethod() should return array{class-string|object, string} but returns non-empty-list<string>.'
identifier: return.type
count: 1
path: flight/core/Dispatcher.php

@ -154,8 +154,7 @@ class DispatcherTest extends TestCase
$this->expectException(Exception::class); $this->expectException(Exception::class);
$this->expectExceptionMessage( $this->expectExceptionMessage(
"Class 'NonExistentClass' not found. " "Class 'NonExistentClass' not found. Is it being correctly autoloaded with Flight::path()?"
. "Is it being correctly autoloaded with Flight::path()?"
); );
$this->dispatcher->execute(['NonExistentClass', 'nonExistentMethod']); $this->dispatcher->execute(['NonExistentClass', 'nonExistentMethod']);
@ -285,8 +284,7 @@ class DispatcherTest extends TestCase
$this->expectException(ArgumentCountError::class); $this->expectException(ArgumentCountError::class);
$this->expectExceptionMessageMatches( $this->expectExceptionMessageMatches(
'#Too few arguments to function tests\\\\classes\\\\TesterClass::__construct\(\), 1 passed' '#Too few arguments to function tests\\\\classes\\\\TesterClass::__construct\(\), 1 passed .+ and exactly 6 expected#'
. ' .+ and exactly 6 expected#'
); );
$this->dispatcher->execute(TesterClass::class . '->instanceMethod'); $this->dispatcher->execute(TesterClass::class . '->instanceMethod');

@ -336,23 +336,15 @@ class RequestBodyParserTest extends TestCase
$parts[] = "Content-Disposition: form-data; name=\"\"; filename=\"empty.txt\"\r\n\r\nemptyNameValue"; $parts[] = "Content-Disposition: form-data; name=\"\"; filename=\"empty.txt\"\r\n\r\nemptyNameValue";
// G: invalid filename triggers sanitized fallback // G: invalid filename triggers sanitized fallback
$parts[] = "Content-Disposition: form-data; " $parts[] = "Content-Disposition: form-data; name=\"filebad\"; filename=\"a*b?.txt\"\r\nContent-Type: text/plain\r\n\r\nFILEBAD";
. "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) // H1 & H2: two files same key for aggregation logic (arrays)
$parts[] = "Content-Disposition: form-data; " $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"one.txt\"\r\nContent-Type: text/plain\r\n\r\nONE";
. "name=\"filemulti\"; "
. "filename=\"one.txt\"\r\nContent-Type: text/plain\r\n\r\nONE";
$parts[] = "Content-Disposition: form-data; " $parts[] = "Content-Disposition: form-data; name=\"filemulti\"; filename=\"two.txt\"\r\nContent-Type: text/plain\r\n\r\nTWO";
. "name=\"filemulti\"; "
. "filename=\"two.txt\"\r\nContent-Type: text/plain\r\n\r\nTWO";
// I: file exceeding total bytes triggers UPLOAD_ERR_INI_SIZE // I: file exceeding total bytes triggers UPLOAD_ERR_INI_SIZE
$parts[] = "Content-Disposition: form-data; " $parts[] = "Content-Disposition: form-data; name=\"filebig\"; filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\n"
. "name=\"filebig\"; "
. "filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\n"
. str_repeat('A', 10); . str_repeat('A', 10);
// Build full body // Build full body
@ -410,13 +402,9 @@ class RequestBodyParserTest extends TestCase
// and header param extraction (preg_match_all) // and header param extraction (preg_match_all)
$boundary = 'BOUNDARYEMPTY'; $boundary = 'BOUNDARYEMPTY';
$validFilePart = "Content-Disposition: form-data; " $validFilePart = "Content-Disposition: form-data; name=\"fileok\"; filename=\"ok.txt\"\r\nContent-Type: text/plain\r\n\r\nOK";
. "name=\"fileok\"; "
. "filename=\"ok.txt\"\r\nContent-Type: text/plain\r\n\r\nOK";
$emptyNameFilePart = "Content-Disposition: form-data; " $emptyNameFilePart = "Content-Disposition: form-data; name=\"[]\"; filename=\"empty.txt\"\r\nContent-Type: text/plain\r\n\r\nSHOULD_SKIP";
. "name=\"[]\"; "
. "filename=\"empty.txt\"\r\nContent-Type: text/plain\r\n\r\nSHOULD_SKIP";
$body = '--' $body = '--'
. $boundary . $boundary

@ -33,8 +33,7 @@ class ContainerDefault
public function testUi(): void public function testUi(): void
{ {
echo '<span id="infotext">Route text:</span> ' echo '<span id="infotext">Route text:</span> The container successfully injected a value into the engine! Engine class: <b>'
. 'The container successfully injected a value into the engine! Engine class: <b>'
. get_class($this->app) . get_class($this->app)
. '</b> test_me_out Value: <b>' . '</b> test_me_out Value: <b>'
. $this->app->get('test_me_out') . $this->app->get('test_me_out')

@ -71,6 +71,27 @@ class AiGenerateInstructionsCommandTest extends TestCase
file_put_contents(self::$in, implode("\n", $lines) . "\n"); file_put_contents(self::$in, implode("\n", $lines) . "\n");
} }
/**
* Default interactive answers (templating default is twig).
*
* @return array<int,string>
*/
protected function defaultAnswers(): array
{
return [
'desc',
'mysql',
'twig',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info',
];
}
protected function setProjectRoot($command, $path) protected function setProjectRoot($command, $path)
{ {
$reflection = new \ReflectionClass(get_class($command)); $reflection = new \ReflectionClass(get_class($command));
@ -97,7 +118,7 @@ class AiGenerateInstructionsCommandTest extends TestCase
$this->setInput([ $this->setInput([
'desc', 'desc',
'none', 'none',
'latte', 'twig',
'y', 'y',
'y', 'y',
'none', 'none',
@ -121,26 +142,15 @@ class AiGenerateInstructionsCommandTest extends TestCase
$this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou)); $this->assertStringContainsString('Missing AI configuration', file_get_contents(self::$ou));
} }
public function testWritesInstructionsToFiles() public function testWritesInstructionsToAgentsMdOnly()
{ {
$creds = [ $creds = [
'api_key' => 'key', 'api_key' => 'key',
'model' => 'gpt-4o', 'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com', 'base_url' => 'https://api.openai.com',
]; ];
$this->setInput([ $this->setInput($this->defaultAnswers());
'desc', $mockInstructions = "# Project Instructions\n\nUse MySQL, Twig, Docker.";
'mysql',
'latte',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info'
]);
$mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker.";
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([ ->setConstructorArgs([
[ [
@ -163,32 +173,117 @@ class AiGenerateInstructionsCommandTest extends TestCase
'ai:generate-instructions', 'ai:generate-instructions',
]); ]);
$this->assertSame(0, $result); $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->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 = [ $creds = [
'api_key' => 'key', 'api_key' => 'key',
'model' => 'gpt-4o', 'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com', 'base_url' => 'https://api.openai.com',
]; ];
$this->setInput([ $existing = "# Existing AGENTS\n\nKeep this context.";
'desc', file_put_contents($this->baseDir . 'AGENTS.md', $existing);
'mysql', $this->setInput($this->defaultAnswers());
'latte',
'y', $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
'y', ->setConstructorArgs([
'flight/lib', [
'Docker', 'runway' => ['ai' => $creds]
'2', ]
'y', ])
'context info' ->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) $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([ ->setConstructorArgs([
[ [
@ -220,18 +315,7 @@ class AiGenerateInstructionsCommandTest extends TestCase
'model' => 'gpt-4o', 'model' => 'gpt-4o',
'base_url' => 'https://api.openai.com', 'base_url' => 'https://api.openai.com',
]; ];
$this->setInput([ $this->setInput($this->defaultAnswers());
'desc',
'mysql',
'latte',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info'
]);
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([ ->setConstructorArgs([
[ [
@ -263,19 +347,8 @@ class AiGenerateInstructionsCommandTest extends TestCase
]; ];
$configFile = $this->baseDir . 'old-config.json'; $configFile = $this->baseDir . 'old-config.json';
file_put_contents($configFile, json_encode($creds)); file_put_contents($configFile, json_encode($creds));
$this->setInput([ $this->setInput($this->defaultAnswers());
'desc', $mockInstructions = "# Project Instructions\n\nUse MySQL, Twig, Docker.";
'mysql',
'latte',
'y',
'y',
'flight/lib',
'Docker',
'2',
'y',
'context info'
]);
$mockInstructions = "# Project Instructions\n\nUse MySQL, Latte, Docker.";
// runway key is MISSING from config to trigger deprecated logic // runway key is MISSING from config to trigger deprecated logic
$cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class) $cmd = $this->getMockBuilder(AiGenerateInstructionsCommand::class)
->setConstructorArgs([[]]) ->setConstructorArgs([[]])
@ -297,6 +370,7 @@ class AiGenerateInstructionsCommandTest extends TestCase
]); ]);
$this->assertSame(0, $result); $this->assertSame(0, $result);
$this->assertStringContainsString('The --config-file option is deprecated', file_get_contents(self::$ou)); $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');
} }
} }

@ -33,12 +33,14 @@ class ControllerCommandTest extends TestCase
unlink(static::$ou); unlink(static::$ou);
} }
if (file_exists(__DIR__ . '/controllers/TestController.php')) { $controllerFile = __DIR__ . '/Controller/TestController.php';
unlink(__DIR__ . '/controllers/TestController.php'); if (file_exists($controllerFile)) {
unlink($controllerFile);
} }
if (file_exists(__DIR__ . '/controllers/')) { $controllerDir = __DIR__ . '/Controller/';
rmdir(__DIR__ . '/controllers/'); if (is_dir($controllerDir)) {
rmdir($controllerDir);
} }
// Thanks Windows // Thanks Windows
@ -65,8 +67,8 @@ class ControllerCommandTest extends TestCase
public function testControllerAlreadyExists(): void public function testControllerAlreadyExists(): void
{ {
$app = $this->newApp('test', '0.0.1'); $app = $this->newApp('test', '0.0.1');
mkdir(__DIR__ . '/controllers/'); mkdir(__DIR__ . '/Controller/');
file_put_contents(__DIR__ . '/controllers/TestController.php', '<?php class TestController {}'); file_put_contents(__DIR__ . '/Controller/TestController.php', '<?php class TestController {}');
$app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']])); $app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']]));
$app->handle(['runway', 'make:controller', 'Test']); $app->handle(['runway', 'make:controller', 'Test']);
@ -79,6 +81,10 @@ class ControllerCommandTest extends TestCase
$app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']])); $app->add(new ControllerCommand(['runway' => ['app_root' => 'tests/commands/']]));
$app->handle(['runway', 'make:controller', 'Test']); $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);
} }
} }

@ -153,20 +153,20 @@ Flight::group('', function () {
// Test 14: Overwrite the body with a middleware // Test 14: Overwrite the body with a middleware
Flight::route('/overwrite', function () { Flight::route('/overwrite', function () {
echo <<<'html' echo <<<'HTML'
<span id="infotext">Route text:</span> <span id="infotext">Route text:</span>
This route status is that it This route status is that it
<span style="color:red; font-weight: bold;">failed</span> <span style="color:red; font-weight: bold;">failed</span>
html; HTML;
})->addMiddleware([new OverwriteBodyMiddleware()]); })->addMiddleware([new OverwriteBodyMiddleware()]);
// Test 15: UTF8 Chars in url // Test 15: UTF8 Chars in url
Flight::route('/わたしはひとです', function () { Flight::route('/わたしはひとです', function () {
echo <<<'html' echo <<<'HTML'
<span id="infotext">Route text:</span> <span id="infotext">Route text:</span>
This route status is that it This route status is that it
<span style="color:green; font-weight: bold;">succeeded はい!!!</span> <span style="color:green; font-weight: bold;">succeeded はい!!!</span>
html; HTML;
}); });
// Test 16: UTF8 Chars in url with utf8 params // Test 16: UTF8 Chars in url with utf8 params

Loading…
Cancel
Save