}> */ public static function buildingBlocks(): array { // [function-name, [[paramName, ?defaultValue, hasDefault], ...]] return [ ['actionResolveModelOrFail', [ ['finder', null, false], ['rawId', null, false], ['notFoundFlashKey', null, false], ['redirectPath', null, false], ]], ['actionAuthorizeAndExtractCapabilities', [ ['abilityKey', null, false], ['context', null, false], ['forbiddenStrategy', 'redirect', true], ]], ['actionDeriveTenantScope', [ ['capabilities', null, false], ['allTenantsFlagKey', 'can_manage_all_tenants', true], ['allowedListKey', 'allowed_tenant_ids', true], ]], ['actionEnforceCanViewPage', [ ['capabilities', null, false], ['flagKey', 'can_view_page', true], ['forbiddenStrategy', 'redirect', true], ]], ['actionBuildViewAuth', [ ['capabilities', null, false], ['whitelistedFlags', null, false], ]], ['actionFragmentResolveOrStatus', [ ['finder', null, false], ['rawId', null, false], ['abilityKey', null, false], ['context', null, false], ]], ]; } public function testHelperFileExistsAndIsRegistered(): void { $this->assertFileExists($this->projectRootPath() . '/core/Support/helpers/action_context.php'); $helpersFile = $this->readProjectFile('core/Support/helpers.php'); $this->assertStringContainsString( "require __DIR__ . '/helpers/action_context.php';", $helpersFile, 'helpers/action_context.php must be required from core/Support/helpers.php' ); } public function testBuildingBlocksExistWithStableSignatures(): void { foreach (self::buildingBlocks() as [$fnName, $expectedParams]) { $this->assertTrue( function_exists($fnName), "Helper function '{$fnName}' must exist (frozen by Step 1)." ); $reflection = new ReflectionFunction($fnName); $actualParams = $reflection->getParameters(); $this->assertSameSize( $expectedParams, $actualParams, "Parameter count for '{$fnName}' has changed." ); foreach ($expectedParams as $i => [$expectedName, $expectedDefault, $hasDefault]) { $this->assertSame( $expectedName, $actualParams[$i]->getName(), "Parameter #{$i} of '{$fnName}' has been renamed." ); $this->assertSame( $hasDefault, $actualParams[$i]->isDefaultValueAvailable(), "Parameter '{$expectedName}' of '{$fnName}' default-availability has changed." ); if ($hasDefault) { $this->assertSame( $expectedDefault, $actualParams[$i]->getDefaultValue(), "Parameter '{$expectedName}' of '{$fnName}' default has changed." ); } } } } public function testTenantScopeReturnDocblockIsFrozen(): void { $reflection = new ReflectionFunction('actionDeriveTenantScope'); $doc = (string) $reflection->getDocComment(); $this->assertStringContainsString( "@return array{scope: 'all'|'list', ids: list}", $doc, "actionDeriveTenantScope must declare PHPStan array-shape return for GR-SEC-009." ); } public function testFragmentResolveReturnDocblockIsFrozen(): void { $reflection = new ReflectionFunction('actionFragmentResolveOrStatus'); $doc = (string) $reflection->getDocComment(); $this->assertMatchesRegularExpression( "/@return array\{status:[^}]*'ok'[^}]*'forbidden'[^}]*'not_found'[^}]*'invalid_id'/", $doc, "actionFragmentResolveOrStatus must declare PHPStan array-shape return covering all 4 status values." ); } public function testEnforceCanViewPageDocblockHasStrictComparisonNote(): void { $reflection = new ReflectionFunction('actionEnforceCanViewPage'); $doc = (string) $reflection->getDocComment(); $this->assertStringContainsString( 'Strict comparison (=== true)', $doc, "actionEnforceCanViewPage docblock must document strict-comparison contract." ); $this->assertStringContainsString( 'No truthy coercion', $doc, "actionEnforceCanViewPage docblock must document no-truthy-coercion contract." ); } public function testBuildViewAuthDocblockHasEscapeWarning(): void { $reflection = new ReflectionFunction('actionBuildViewAuth'); $doc = (string) $reflection->getDocComment(); $this->assertStringContainsString( 'MUST e()-escape', $doc, "actionBuildViewAuth docblock must require e()-escaping per GR-SEC-010." ); } public function testAggregatorDocblocksWarnAboutCsrf(): void { $contents = $this->readProjectFile('core/Support/helpers/action_context.php'); $count = preg_match_all( '/MUST call actionRequireCsrf\(\) BEFORE this aggregator/', $contents ); $this->assertSame( 3, $count, 'Each of the 3 aggregators (actionEditContext, actionCreateContext, actionFragmentContext) must carry the explicit CSRF-warning in its docblock (GR-SEC-001).' ); } public function testAggregatorsExistButAreNotFrozen(): void { // We assert existence only — signatures may evolve in Step 2. $this->assertTrue(function_exists('actionEditContext')); $this->assertTrue(function_exists('actionCreateContext')); $this->assertTrue(function_exists('actionFragmentContext')); } public function testNoProductionCallSitesYet(): void { $root = $this->projectRootPath(); $names = [ 'actionResolveModelOrFail', 'actionAuthorizeAndExtractCapabilities', 'actionDeriveTenantScope', 'actionEnforceCanViewPage', 'actionBuildViewAuth', 'actionFragmentResolveOrStatus', 'actionEditContext', 'actionCreateContext', 'actionFragmentContext', ]; $hits = []; foreach (['pages', 'modules'] as $dir) { $base = $root . '/' . $dir; if (!is_dir($base)) { continue; } $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)); /** @var \SplFileInfo $file */ foreach ($iterator as $file) { if (!$file->isFile() || $file->getExtension() !== 'php') { continue; } $content = (string) file_get_contents($file->getPathname()); foreach ($names as $name) { if (preg_match('/\b' . preg_quote($name, '/') . '\s*\(/', $content)) { $hits[] = $name . ' in ' . str_replace($root . '/', '', $file->getPathname()); } } } } $this->assertSame( [], $hits, "Step 1 must not introduce production call-sites — defer to Step 2 (Departments-Edit pilot):\n" . implode("\n", $hits) ); } /** * Ensure the `mixed` return type of building blocks is preserved where * documented. Catches accidental tightening that would break Step 2. */ public function testResolveModelOrFailReturnsMixed(): void { $reflection = new ReflectionFunction('actionResolveModelOrFail'); $returnType = $reflection->getReturnType(); $this->assertInstanceOf(ReflectionNamedType::class, $returnType); /** @var ReflectionNamedType $returnType */ $this->assertSame('mixed', $returnType->getName()); } }