TenantAtlas/tests/Support/OpsUx/SourceFileScanner.php
ahmido f13a4ce409 feat(110): Ops-UX enterprise start/dedup standard (repo-wide) (#134)
Implements Spec 110 Ops‑UX Enforcement and applies the repo‑wide “enterprise” standard for operation start + dedup surfaces.

Key points
- Start surfaces: only ephemeral queued toast (no DB notifications for started/queued/running).
- Dedup paths: canonical “already queued” toast.
- Progress refresh: dispatch run-enqueued browser event so the global widget updates immediately.
- Completion: exactly-once terminal DB notification on completion (per Ops‑UX contract).

Tests & formatting
- Full suite: 1738 passed, 8 skipped (8477 assertions).
- Pint: `vendor/bin/sail bin pint --dirty --format agent` (pass).

Notable change
- Removed legacy `RunStatusChangedNotification` (replaced by the terminal-only completion notification policy).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #134
2026-02-24 09:30:15 +00:00

104 lines
2.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Support\OpsUx;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
final class SourceFileScanner
{
/**
* @param list<string> $roots
* @param list<string> $excludedAbsolutePaths
* @return list<string>
*/
public static function phpFiles(array $roots, array $excludedAbsolutePaths = []): array
{
$files = [];
$excluded = array_fill_keys(array_map(self::normalizePath(...), $excludedAbsolutePaths), true);
foreach ($roots as $root) {
$root = self::normalizePath($root);
if (! is_dir($root)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)
);
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = self::normalizePath($file->getPathname());
if (pathinfo($path, PATHINFO_EXTENSION) !== 'php') {
continue;
}
if (isset($excluded[$path])) {
continue;
}
$files[] = $path;
}
}
sort($files);
return array_values(array_unique($files));
}
public static function projectRoot(): string
{
return self::normalizePath(dirname(__DIR__, 3));
}
public static function relativePath(string $absolutePath): string
{
$absolutePath = self::normalizePath($absolutePath);
$root = self::projectRoot();
if (str_starts_with($absolutePath, $root.'/')) {
return substr($absolutePath, strlen($root) + 1);
}
return $absolutePath;
}
public static function read(string $path): string
{
return (string) file_get_contents($path);
}
public static function snippet(string $source, int $line, int $contextLines = 2): string
{
$allLines = preg_split('/\R/', $source) ?: [];
$line = max(1, $line);
$start = max(1, $line - $contextLines);
$end = min(count($allLines), $line + $contextLines);
$snippet = [];
for ($index = $start; $index <= $end; $index++) {
$prefix = $index === $line ? '>' : ' ';
$snippet[] = sprintf('%s%4d | %s', $prefix, $index, $allLines[$index - 1] ?? '');
}
return implode("\n", $snippet);
}
private static function normalizePath(string $path): string
{
return str_replace('\\', '/', $path);
}
}