TenantAtlas/apps/platform/tests/Feature/Guards/BranchTopologyContractTest.php
Ahmed Darrazi 8144a3be78
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 7m2s
feat: implement spec 439 branch and evidence truth
2026-07-11 21:13:42 +02:00

624 lines
26 KiB
PHP

<?php
declare(strict_types=1);
use Symfony\Component\Process\Process;
function branchTopologyGit(string $repository, string ...$arguments): string
{
$process = new Process(['git', ...$arguments], $repository);
$process->setTimeout(15);
$process->run();
expect($process->isSuccessful())
->toBeTrue(trim($process->getErrorOutput()) ?: trim($process->getOutput()));
return trim($process->getOutput());
}
function branchTopologyCommit(string $repository, string $path, string $contents, string $message): string
{
$absolutePath = $repository.DIRECTORY_SEPARATOR.$path;
$directory = dirname($absolutePath);
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
file_put_contents($absolutePath, $contents);
branchTopologyGit($repository, 'add', $path);
branchTopologyGit($repository, 'commit', '-m', $message);
return branchTopologyGit($repository, 'rev-parse', 'HEAD');
}
function branchTopologyRepository(): string
{
$repository = sys_get_temp_dir().DIRECTORY_SEPARATOR.'tenantpilot-branch-topology-'.bin2hex(random_bytes(8));
mkdir($repository, 0777, true);
branchTopologyGit($repository, 'init', '-b', 'main');
branchTopologyGit($repository, 'config', 'user.email', 'branch-topology@example.test');
branchTopologyGit($repository, 'config', 'user.name', 'Branch Topology Fixture');
branchTopologyCommit($repository, 'baseline.txt', "baseline\n", 'baseline');
return $repository;
}
function branchTopologyRemoveDirectory(string $directory): void
{
if (! is_dir($directory)) {
return;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($directory);
}
/**
* @return array{expectedBranch: string, targetCommit: string, featureCommit: string}
*/
function branchTopologyPrepareStream(
string $repository,
string $family,
bool $remoteRef,
bool $localRef = true,
bool $diverged = false,
): array {
$expectedBranch = $family === 'platform' ? 'platform-dev' : 'website-dev';
$baseline = branchTopologyGit($repository, 'rev-parse', 'main');
branchTopologyGit($repository, 'branch', $expectedBranch, $baseline);
if ($diverged) {
branchTopologyGit($repository, 'checkout', $expectedBranch);
$targetCommit = branchTopologyCommit(
$repository,
$family.'-integration.txt',
$family." integration\n",
$family.' integration',
);
} else {
$targetCommit = $baseline;
}
branchTopologyGit($repository, 'checkout', '-b', 'feat/'.$family.'-fixture', $baseline);
$featureCommit = branchTopologyCommit(
$repository,
$family.'-feature.txt',
$family." feature\n",
$family.' feature',
);
if ($remoteRef) {
branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/'.$expectedBranch, $targetCommit);
}
if (! $localRef) {
branchTopologyGit($repository, 'branch', '-D', $expectedBranch);
}
return compact('expectedBranch', 'targetCommit', 'featureCommit');
}
/**
* @param list<string> $arguments
* @param array<string, string|false> $environment
*/
function branchTopologyResolve(
string $repository,
array $arguments,
array $environment = [],
bool $machineReadable = true,
): Process {
$isolatedEnvironment = [
'TENANTATLAS_BRANCH_FAMILY' => false,
'TENANTATLAS_WORK_CLASSIFICATION' => false,
'TENANTATLAS_PR_TARGET' => false,
'TENANTATLAS_ACTIVE_SPEC' => false,
'TENANTATLAS_BASE_REF' => false,
'TENANTATLAS_REPO_SCOPE' => false,
'GITHUB_BASE_REF' => false,
'GITEA_BASE_REF' => false,
];
$command = [repo_path('scripts/resolve-feature-base')];
if ($machineReadable) {
$command[] = '--format=kv';
}
$process = new Process(
[...$command, ...$arguments],
$repository,
array_replace($isolatedEnvironment, $environment),
);
$process->setTimeout(15);
$process->run();
return $process;
}
/**
* @return array<string, string>
*/
function branchTopologyKeyValues(string $output): array
{
$values = [];
foreach (preg_split('/\R/', trim($output)) ?: [] as $line) {
if (! str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$values[$key] = $value;
}
return $values;
}
function branchTopologyFixture(Closure $assertions): void
{
$repository = branchTopologyRepository();
try {
$assertions($repository);
} finally {
branchTopologyRemoveDirectory($repository);
}
}
it('[BS-001] selects the platform remote ref with its merge-base result', function (): void {
branchTopologyFixture(function (string $repository): void {
$stream = branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve($repository, ['--branch-family=platform']);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('platform')
->and($values['expected_branch'] ?? null)->toBe('platform-dev')
->and($values['selected_ref'] ?? null)->toBe('origin/platform-dev')
->and($values['target_commit'] ?? null)->toBe($stream['targetCommit'])
->and($values['merge_base'] ?? null)->toBe($stream['targetCommit'])
->and($values['base_range'] ?? null)->toBe($stream['targetCommit'].'...HEAD');
});
});
it('[BS-002] falls back only to the local platform ref with its merge-base result', function (): void {
branchTopologyFixture(function (string $repository): void {
$stream = branchTopologyPrepareStream($repository, 'platform', remoteRef: false);
$process = branchTopologyResolve($repository, ['--branch-family=platform']);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('platform')
->and($values['expected_branch'] ?? null)->toBe('platform-dev')
->and($values['selected_ref'] ?? null)->toBe('platform-dev')
->and($values['target_commit'] ?? null)->toBe($stream['targetCommit'])
->and($values['merge_base'] ?? null)->toBe($stream['targetCommit'])
->and($values['base_range'] ?? null)->toBe($stream['targetCommit'].'...HEAD');
});
});
it('[BS-003] selects the website remote ref with its merge-base result', function (): void {
branchTopologyFixture(function (string $repository): void {
$stream = branchTopologyPrepareStream($repository, 'website', remoteRef: true);
$process = branchTopologyResolve($repository, ['--branch-family=website']);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('website')
->and($values['expected_branch'] ?? null)->toBe('website-dev')
->and($values['selected_ref'] ?? null)->toBe('origin/website-dev')
->and($values['target_commit'] ?? null)->toBe($stream['targetCommit'])
->and($values['merge_base'] ?? null)->toBe($stream['targetCommit'])
->and($values['base_range'] ?? null)->toBe($stream['targetCommit'].'...HEAD');
});
});
it('[BS-004] falls back only to the local website ref with its merge-base result', function (): void {
branchTopologyFixture(function (string $repository): void {
$stream = branchTopologyPrepareStream($repository, 'website', remoteRef: false);
$process = branchTopologyResolve($repository, ['--branch-family=website']);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('website')
->and($values['expected_branch'] ?? null)->toBe('website-dev')
->and($values['selected_ref'] ?? null)->toBe('website-dev')
->and($values['target_commit'] ?? null)->toBe($stream['targetCommit'])
->and($values['merge_base'] ?? null)->toBe($stream['targetCommit'])
->and($values['base_range'] ?? null)->toBe($stream['targetCommit'].'...HEAD');
});
});
it('[BS-005] accepts dev only for an explicitly classified promotion', function (): void {
branchTopologyFixture(function (string $repository): void {
$baseline = branchTopologyGit($repository, 'rev-parse', 'main');
branchTopologyGit($repository, 'branch', 'dev', $baseline);
branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/dev', $baseline);
branchTopologyGit($repository, 'checkout', '-b', 'feat/promotion-fixture', $baseline);
branchTopologyCommit($repository, 'promotion.txt', "promotion\n", 'promotion feature');
$process = branchTopologyResolve($repository, [
'--branch-family=repository-wide',
'--work-classification=promotion',
'--pull-request-target=dev',
]);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('repository-wide')
->and($values['work_classification'] ?? null)->toBe('promotion')
->and($values['expected_branch'] ?? null)->toBe('dev')
->and($values['selected_ref'] ?? null)->toBe('origin/dev')
->and($values['target_commit'] ?? null)->toBe($baseline)
->and($values['merge_base'] ?? null)->toBe($baseline)
->and($values['base_range'] ?? null)->toBe($baseline.'...HEAD');
});
});
it('[BS-006] fails closed when platform family refs are missing', function (): void {
branchTopologyFixture(function (string $repository): void {
$baseline = branchTopologyGit($repository, 'rev-parse', 'main');
branchTopologyGit($repository, 'branch', 'dev', $baseline);
branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/dev', $baseline);
$process = branchTopologyResolve($repository, ['--branch-family=platform']);
$error = $process->getErrorOutput();
expect($process->getExitCode())->toBe(2)
->and($error)->toContain('status=failed', 'reason=missing-family-ref')
->and($error)->toContain(
'branch_family=platform',
'expected_branch=platform-dev',
'attempted_refs=origin/platform-dev,platform-dev',
'guidance=Declare a consistent branch family',
'diff_ran=false',
)
->and($error)->not->toContain(
'selected_ref=origin/dev',
'selected_ref=dev',
'HEAD~1',
'merge_base=',
'base_range=',
);
});
});
it('[BS-007] fails closed when website family refs are missing', function (): void {
branchTopologyFixture(function (string $repository): void {
$baseline = branchTopologyGit($repository, 'rev-parse', 'main');
branchTopologyGit($repository, 'branch', 'platform-dev', $baseline);
branchTopologyGit($repository, 'branch', 'dev', $baseline);
branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/dev', $baseline);
$process = branchTopologyResolve($repository, ['--branch-family=website']);
$error = $process->getErrorOutput();
expect($process->getExitCode())->toBe(2)
->and($error)->toContain(
'reason=missing-family-ref',
'branch_family=website',
'expected_branch=website-dev',
'attempted_refs=origin/website-dev,website-dev',
'guidance=Declare a consistent branch family',
'diff_ran=false',
)
->and($error)->not->toContain(
'selected_ref=origin/dev',
'selected_ref=dev',
'HEAD~1',
'merge_base=',
'base_range=',
);
});
});
it('[BS-008] returns only the platform feature path for diverged history', function (): void {
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true, diverged: true);
$process = branchTopologyResolve($repository, ['--branch-family=platform']);
$values = branchTopologyKeyValues($process->getOutput());
$changed = branchTopologyGit($repository, 'diff', '--name-only', $values['base_range']);
expect($process->getExitCode())->toBe(0)
->and($changed)->toBe('platform-feature.txt')
->and($values['merge_base'] ?? null)->not->toBe($values['target_commit'] ?? null);
});
});
it('[BS-009] returns only the website feature path for diverged history', function (): void {
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'website', remoteRef: true, diverged: true);
$process = branchTopologyResolve($repository, ['--branch-family=website']);
$values = branchTopologyKeyValues($process->getOutput());
$changed = branchTopologyGit($repository, 'diff', '--name-only', $values['base_range']);
expect($process->getExitCode())->toBe(0)
->and($changed)->toBe('website-feature.txt')
->and($values['merge_base'] ?? null)->not->toBe($values['target_commit'] ?? null);
});
});
it('[BS-010] rejects an explicit platform family plus website PR target', function (): void {
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve($repository, [
'--branch-family=platform',
'--pull-request-target=website-dev',
]);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=branch-family-conflict',
'explicit=platform',
'pull_request_target=website',
'guidance=Declare a consistent branch family',
'diff_ran=false',
);
});
});
it('[BS-011] rejects conflicting branch-name and repository-scope signals', function (): void {
branchTopologyFixture(function (string $repository): void {
$specPath = $repository.'/spec.md';
file_put_contents($specPath, "# Ambiguous fixture\n\nNo branch metadata.\n");
$process = branchTopologyResolve($repository, [
'--branch-name=feat/platform-fixture',
'--repo-scope=website',
'--spec='.$specPath,
]);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=ambiguous-branch-family',
'branch_name=feat/platform-fixture',
'repo_scope=website',
'spec_path='.$specPath,
'guidance=Declare a consistent branch family',
'diff_ran=false',
);
});
});
it('[BS-012] rejects an explicit ref from the wrong family before merge-base work', function (): void {
branchTopologyFixture(function (string $repository): void {
$baseline = branchTopologyGit($repository, 'rev-parse', 'main');
branchTopologyGit($repository, 'branch', 'platform-dev', $baseline);
branchTopologyGit($repository, 'branch', 'website-dev', $baseline);
branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/website-dev', $baseline);
$process = branchTopologyResolve($repository, [
'--branch-family=platform',
'--ref=origin/website-dev',
]);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=wrong-family-ref',
'expected_branch=platform-dev',
'explicit_ref=origin/website-dev',
'guidance=Declare a consistent branch family',
'diff_ran=false',
)
->and($process->getErrorOutput())->not->toContain(
'target_commit=',
'merge_base=',
'base_range=',
);
});
});
it('proves Stage 1 priority, diagnostics, ancestry failure, and executable portability', function (): void {
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'website', remoteRef: true);
$process = branchTopologyResolve($repository, ['--pull-request-target=website-dev']);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('website')
->and($values['family_source'] ?? null)->toBe('pull-request-target');
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$specPath = $repository.'/spec.md';
file_put_contents($specPath, "**Feature Base**: platform-dev\n**Normal Merge Target**: platform-dev\n");
$process = branchTopologyResolve($repository, ['--spec='.$specPath]);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('platform')
->and($values['family_source'] ?? null)->toBe('spec-metadata');
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve($repository, [
'--branch-name=feat/platform-policy-fixture',
'--repo-scope=platform',
]);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('platform')
->and($values['family_source'] ?? null)->toBe('branch-and-scope');
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve($repository, ['--branch-family=platform']);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values)->toHaveKeys([
'status',
'branch_family',
'work_classification',
'family_source',
'expected_branch',
'selected_ref',
'target_commit',
'merge_base',
'base_range',
])
->and($values['status'])->toBe('resolved')
->and($values['target_commit'])->toMatch('/^[0-9a-f]{40}$/')
->and($values['merge_base'])->toMatch('/^[0-9a-f]{40}$/')
->and($values['base_range'])->toBe($values['merge_base'].'...HEAD');
});
branchTopologyFixture(function (string $repository): void {
$baseline = branchTopologyGit($repository, 'rev-parse', 'main');
branchTopologyGit($repository, 'branch', 'dev', $baseline);
$process = branchTopologyResolve($repository, ['--pull-request-target=dev']);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=explicit-promotion-classification-required',
'diff_ran=false',
);
});
branchTopologyFixture(function (string $repository): void {
$process = branchTopologyResolve($repository, ['--branch-family=unknown']);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=invalid-branch-family',
'explicit=unknown',
'diff_ran=false',
);
});
branchTopologyFixture(function (string $repository): void {
$specPath = $repository.'/spec.md';
file_put_contents($specPath, "**Feature Base**: platform-dev\n**Normal Merge Target**: platform-dev\n");
$process = branchTopologyResolve($repository, [
'--pull-request-target=website-dev',
'--spec='.$specPath,
]);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=branch-family-conflict',
'selected=website',
'spec_metadata=platform',
'diff_ran=false',
);
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve($repository, ['--branch-name=feat/platform-name-only']);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=ambiguous-branch-family',
'diff_ran=false',
);
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve(
$repository,
[],
['TENANTATLAS_BRANCH_FAMILY' => 'platform'],
);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('platform')
->and($values['family_source'] ?? null)->toBe('explicit');
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'website', remoteRef: true);
$process = branchTopologyResolve(
$repository,
[],
['GITHUB_BASE_REF' => 'website-dev'],
);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('website')
->and($values['family_source'] ?? null)->toBe('pull-request-target');
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve($repository, [
'--branch-family=platform',
'--ref=platform-dev',
]);
expect($process->getExitCode())->toBe(0)
->and(branchTopologyKeyValues($process->getOutput())['selected_ref'] ?? null)->toBe('platform-dev');
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve(
$repository,
['--pull-request-target=platform-dev'],
['GITHUB_BASE_REF' => 'refs/heads/platform-dev'],
);
expect($process->getExitCode())->toBe(0)
->and(branchTopologyKeyValues($process->getOutput())['branch_family'] ?? null)->toBe('platform');
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve($repository, [
'--branch-family=platform',
'--branch-name=feat/website-stale-hint',
'--repo-scope=website',
]);
$values = branchTopologyKeyValues($process->getOutput());
expect($process->getExitCode())->toBe(0)
->and($values['branch_family'] ?? null)->toBe('platform')
->and($values['family_source'] ?? null)->toBe('explicit');
});
branchTopologyFixture(function (string $repository): void {
$baseline = branchTopologyGit($repository, 'rev-parse', 'main');
branchTopologyGit($repository, 'branch', 'dev', $baseline);
branchTopologyGit($repository, 'update-ref', 'refs/remotes/origin/dev', $baseline);
$process = branchTopologyResolve($repository, [
'--branch-family=repository-wide',
'--work-classification=promotion',
]);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=explicit-target-required',
'work_classification=promotion',
'diff_ran=false',
);
});
branchTopologyFixture(function (string $repository): void {
branchTopologyPrepareStream($repository, 'platform', remoteRef: true);
$process = branchTopologyResolve(
$repository,
['--branch-family=platform'],
[],
machineReadable: false,
);
expect($process->getExitCode())->toBe(0)
->and($process->getOutput())->toContain(
'Feature base resolved.',
'Branch family: platform',
'Expected integration branch: platform-dev',
'Selected ref: origin/platform-dev',
'Target commit:',
'Merge base:',
'Base range:',
);
});
branchTopologyFixture(function (string $repository): void {
branchTopologyGit($repository, 'checkout', '--orphan', 'platform-dev');
branchTopologyGit($repository, 'rm', '-rf', '.');
branchTopologyCommit($repository, 'unrelated.txt', "unrelated\n", 'unrelated root');
branchTopologyGit($repository, 'checkout', 'main');
$process = branchTopologyResolve($repository, ['--branch-family=platform']);
expect($process->getExitCode())->toBe(2)
->and($process->getErrorOutput())->toContain(
'reason=no-common-merge-base',
'selected_ref=platform-dev',
'diff_ran=false',
);
});
$resolver = repo_path('scripts/resolve-feature-base');
$syntax = new Process(['bash', '-n', $resolver], repo_root());
$syntax->run();
expect(is_executable($resolver))->toBeTrue()
->and($syntax->getExitCode())->toBe(0, $syntax->getErrorOutput());
});