Files
MokoCLI/lib/Enterprise/SourceResolver.php
T

286 lines
9.1 KiB
PHP
Raw Normal View History

<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoPlatform.Enterprise
* INGROUP: MokoPlatform.Lib
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform
* PATH: /lib/Enterprise/SourceResolver.php
* BRIEF: Resolve the root-level source directory across repos (source/, src/, htdocs/)
*/
declare(strict_types=1);
namespace MokoCli;
/**
* Source Directory Resolver
*
* Provides a single, consistent fallback chain for locating the root-level
* source directory in any MokoCli repository. The preferred directory
* is `source/`, with legacy `src/` and `htdocs/` as fallbacks.
*
* This class exists because Joomla extensions use `src/` for namespace
* autoloading (e.g. administrator/components/com_foo/src/). Renaming our
* root-level source directory to `source/` avoids that collision. During
* the transition period, repos may still use `src/`, so all tooling must
* check both.
*
* Usage:
* $dir = SourceResolver::resolve($repoRoot); // 'source', 'src', or 'htdocs'
* $abs = SourceResolver::resolveAbsolute($repoRoot); // full path or null
* $xmls = SourceResolver::globSource($repoRoot, '*.xml'); // glob under first match
* $path = SourceResolver::findUnderSource($repoRoot, 'core/modules'); // subpath lookup
*
* @since 09.02.00
*/
class SourceResolver
{
/**
* Ordered candidate directories. source/ is preferred, src/ is legacy fallback.
*
* When the migration is complete and all repos use source/, the 'src'
* entry can be removed from this list.
*
* @var string[]
*/
private const CANDIDATES = ['source', 'src', 'htdocs'];
/** Cache of API-resolved entry points keyed by "org/repo". */
private static array $apiCache = [];
/**
* Resolve the source directory name for a repository root.
*
* Resolution order:
* 1. Gitea Manifest API `entry_point` (when GA_TOKEN/GITEA_TOKEN + GITHUB_REPOSITORY are set)
* 2. First candidate directory that exists on the filesystem
* 3. 'source' as the default (e.g. for new repos being scaffolded)
*
* @param string $root Absolute path to the repository root.
* @return string Directory name (e.g. 'source', 'src', 'htdocs').
*/
public static function resolve(string $root): string
{
// Try API first (CI environments where token + repo are available)
$apiResult = self::resolveFromApi($root);
if ($apiResult !== null) {
return $apiResult;
}
// Filesystem fallback
foreach (self::CANDIDATES as $candidate) {
if (is_dir("{$root}/{$candidate}")) {
return $candidate;
}
}
return 'source';
}
/**
* Query the MokoGitea Manifest API for the entry_point field.
*
* Only attempts the call when GA_TOKEN or GITEA_TOKEN is set. Results are
* cached per org/repo for the lifetime of the process.
*
* @param string $root Repository root (used to derive org/repo from git remote).
* @return string|null Directory name from entry_point, or null if unavailable.
*/
public static function resolveFromApi(string $root): ?string
{
$token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: '';
if ($token === '') {
return null;
}
[$org, $repo] = self::resolveOrgRepo($root);
if ($org === '' || $repo === '') {
return null;
}
$cacheKey = "{$org}/{$repo}";
if (array_key_exists($cacheKey, self::$apiCache)) {
return self::$apiCache[$cacheKey];
}
$baseUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
$url = "{$baseUrl}/api/v1/repos/{$org}/{$repo}/manifest";
$ctx = stream_context_create([
'http' => [
'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n",
'timeout' => 5,
'ignore_errors' => true,
],
]);
$body = @file_get_contents($url, false, $ctx);
if ($body === false) {
self::$apiCache[$cacheKey] = null;
return null;
}
$status = 0;
if (isset($http_response_header[0])) {
preg_match('/\d{3}/', $http_response_header[0], $m);
$status = (int) ($m[0] ?? 0);
}
if ($status < 200 || $status >= 300) {
self::$apiCache[$cacheKey] = null;
return null;
}
$data = json_decode($body, true);
$entryPoint = $data['entry_point'] ?? '';
// Normalize: "source/" → "source", "cli/" → "cli"
$result = ($entryPoint !== '') ? rtrim($entryPoint, '/') : null;
self::$apiCache[$cacheKey] = $result;
return $result;
}
/**
* Resolve org/repo from GITHUB_REPOSITORY env or git remote.
*
* @return array{0: string, 1: string}
*/
private static function resolveOrgRepo(string $root): array
{
$envRepo = getenv('GITHUB_REPOSITORY') ?: '';
if ($envRepo !== '' && str_contains($envRepo, '/')) {
return explode('/', $envRepo, 2);
}
$remoteUrl = trim((string) @shell_exec(
'git -C ' . escapeshellarg($root) . ' remote get-url origin 2>/dev/null'
));
if ($remoteUrl !== '' && preg_match('#[/:]([^/]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) {
return [$m[1], $m[2]];
}
return ['', ''];
}
/**
* Resolve the source directory as an absolute path.
*
* @param string $root Absolute path to the repository root.
* @return string|null Absolute path to the source directory, or null if none exists.
*/
public static function resolveAbsolute(string $root): ?string
{
foreach (self::CANDIDATES as $candidate) {
$path = "{$root}/{$candidate}";
if (is_dir($path)) {
return $path;
}
}
return null;
}
/**
* Glob for files under the source directory.
*
* Checks each candidate directory in order and returns matches from the
* first candidate that produces results. This replaces patterns like:
*
* glob("{$root}/src/*.xml")
*
* With the backwards-compatible:
*
* SourceResolver::globSource($root, '*.xml')
*
* @param string $root Absolute path to the repository root.
* @param string $pattern Glob pattern relative to the source directory.
* @return string[] Matched file paths (may be empty).
*/
public static function globSource(string $root, string $pattern): array
{
foreach (self::CANDIDATES as $candidate) {
$dir = "{$root}/{$candidate}";
if (!is_dir($dir)) {
continue;
}
$matches = glob("{$dir}/{$pattern}") ?: [];
if ($matches !== []) {
return $matches;
}
}
return [];
}
/**
* Find a subpath under any source directory candidate.
*
* Useful for locating platform-specific subdirectories like
* `core/modules/` (Dolibarr) or `media/templates/` (Joomla client themes)
* regardless of whether the repo uses `source/` or `src/`.
*
* @param string $root Absolute path to the repository root.
* @param string $subpath Relative path to look for (e.g. 'core/modules', 'index.ts').
* @return string|null Absolute path if found, null otherwise.
*/
public static function findUnderSource(string $root, string $subpath): ?string
{
foreach (self::CANDIDATES as $candidate) {
$full = "{$root}/{$candidate}/{$subpath}";
if (file_exists($full) || is_dir($full)) {
return $full;
}
}
return null;
}
/**
* Get the ordered list of candidate directory names.
*
* Useful for workflows or scripts that need to iterate candidates
* themselves (e.g. building find/grep patterns).
*
* @return string[]
*/
public static function getCandidates(): array
{
return self::CANDIDATES;
}
/**
* Check whether the resolved source directory is a legacy name (src/).
*
* @param string $root Absolute path to the repository root.
* @return bool True if the repo uses src/ instead of source/.
*/
public static function isLegacy(string $root): bool
{
$resolved = self::resolve($root);
return $resolved === 'src';
}
/**
* Emit a deprecation warning to stderr if the repo still uses src/.
*
* CLI tools should call this after resolving the source directory so
* that maintainers know to rename src/ → source/.
*
* @param string $root Absolute path to the repository root.
*/
public static function warnIfLegacy(string $root): void
{
if (self::isLegacy($root)) {
fwrite(STDERR, "⚠ WARNING: This repo uses src/ which is deprecated. Rename to source/ per MokoCli conventions.\n");
}
}
}