From ca55e5d2d2c30fa742645a7441ab08d85b5ddae0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 6 Jun 2026 08:58:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(core):=20add=20SourceResolver=20for=20back?= =?UTF-8?q?wards-compatible=20src/=20=E2=86=92=20source/=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces SourceResolver utility class with source/ → src/ → htdocs/ fallback chain, replacing hardcoded src/ references across 28 files. This enables renaming root-level src/ to source/ in all repos while maintaining backwards compatibility during the transition. Phase 1: New lib/Enterprise/SourceResolver.php with resolve(), resolveAbsolute(), globSource(), findUnderSource(), warnIfLegacy() Phase 2: Updated 19 CLI/deploy tools to use SourceResolver Phase 3: Updated 7 validator/lib files (McpServerPlugin, PackageBuilder, RepositorySynchronizer, auto_detect_platform, check_dolibarr_module, check_client_theme, check_structure) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/deploy_joomla.php | 12 +- cli/joomla_build.php | 13 +- cli/joomla_release.php | 7 +- cli/manifest_element.php | 7 +- cli/manifest_licensing.php | 6 +- cli/package_build.php | 13 +- cli/release_create.php | 9 +- cli/release_package.php | 30 ++-- cli/release_promote.php | 6 +- cli/release_validate.php | 13 +- cli/theme_lint.php | 13 +- cli/updates_xml_build.php | 4 +- cli/version_bump.php | 14 +- cli/version_bump_remote.php | 12 +- cli/version_check.php | 5 +- cli/version_read.php | 8 +- cli/version_set_platform.php | 10 +- deploy/deploy-sftp.php | 9 +- lib/Enterprise/ManifestReader.php | 18 +- lib/Enterprise/PackageBuilder.php | 16 +- lib/Enterprise/Plugins/McpServerPlugin.php | 47 +++--- lib/Enterprise/RepositorySynchronizer.php | 2 +- lib/Enterprise/SourceResolver.php | 187 +++++++++++++++++++++ validate/auto_detect_platform.php | 29 ++-- validate/check_changelog.php | 4 +- validate/check_client_theme.php | 50 +++--- validate/check_dolibarr_module.php | 25 +-- validate/check_structure.php | 2 +- 28 files changed, 387 insertions(+), 184 deletions(-) create mode 100644 lib/Enterprise/SourceResolver.php diff --git a/cli/deploy_joomla.php b/cli/deploy_joomla.php index c5c9cc4..d361fbf 100644 --- a/cli/deploy_joomla.php +++ b/cli/deploy_joomla.php @@ -31,7 +31,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; use phpseclib3\Net\SFTP; use phpseclib3\Crypt\PublicKeyLoader; @@ -866,11 +866,11 @@ class DeployJoomla extends CliFramework } } - // 3-5. Fallback chain - foreach (['src', 'htdocs'] as $candidate) { - if (is_dir("{$repoPath}/{$candidate}")) { - return "{$repoPath}/{$candidate}"; - } + // 3-5. Fallback chain (source/ → src/ → htdocs/) + $resolved = SourceResolver::resolveAbsolute($repoPath); + if ($resolved !== null) { + SourceResolver::warnIfLegacy($repoPath); + return $resolved; } // Last resort: repo root itself diff --git a/cli/joomla_build.php b/cli/joomla_build.php index f3ab27d..34d8dbf 100644 --- a/cli/joomla_build.php +++ b/cli/joomla_build.php @@ -19,7 +19,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class JoomlaBuildCli extends CliFramework { @@ -49,17 +49,12 @@ class JoomlaBuildCli extends CliFramework $path = realpath($path) ?: $path; // ── Find source directory ────────────────────────────────────────────── - $srcDir = null; - foreach (['src', 'htdocs'] as $d) { - if (is_dir("{$path}/{$d}")) { - $srcDir = "{$path}/{$d}"; - break; - } - } + $srcDir = SourceResolver::resolveAbsolute($path); if ($srcDir === null) { - $this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}"); + $this->log('ERROR', "::error::No source/ or src/ directory in {$path}"); return 1; } + SourceResolver::warnIfLegacy($path); // ── Find manifest ────────────────────────────────────────────────────── $manifest = $this->findManifest($srcDir); diff --git a/cli/joomla_release.php b/cli/joomla_release.php index 4d99d8a..b763e62 100644 --- a/cli/joomla_release.php +++ b/cli/joomla_release.php @@ -25,7 +25,7 @@ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; -use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory}; +use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, PlatformAdapterFactory, SourceResolver}; /** * Joomla Release Manager @@ -121,11 +121,12 @@ class JoomlaRelease extends CliFramework $this->log('INFO', "Version: {$displayVersion} | Release tag: {$releaseTag}"); // ── Step 3: Build packages ──────────────────────────────────── - $srcDir = is_dir("{$path}/src") ? "{$path}/src" : (is_dir("{$path}/htdocs") ? "{$path}/htdocs" : null); + $srcDir = SourceResolver::resolveAbsolute($path); if ($srcDir === null) { - $this->log('ERROR', 'No src/ or htdocs/ directory'); + $this->log('ERROR', 'No source/ or src/ directory'); return 1; } + SourceResolver::warnIfLegacy($path); $prefix = $this->typePrefix($meta); $zipName = "{$prefix}{$meta['element']}-{$displayVersion}.zip"; diff --git a/cli/manifest_element.php b/cli/manifest_element.php index a3c7e4a..9cf9580 100644 --- a/cli/manifest_element.php +++ b/cli/manifest_element.php @@ -17,7 +17,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class ManifestElementCli extends CliFramework { @@ -48,7 +48,7 @@ class ManifestElementCli extends CliFramework } } $extManifest = null; - $manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []); + $manifestFiles = array_merge(SourceResolver::globSource($root, 'pkg_*.xml'), SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: []); foreach ($manifestFiles as $file) { $c = file_get_contents($file); if (strpos($c, ' block from .mokogitea/manifest.xml and ensures that the @@ -123,8 +123,8 @@ class ManifestLicensingCli extends CliFramework // ── 4. Find Joomla extension manifests ──────────────────────────── $xmlFiles = array_merge( - glob("{$root}/src/*.xml") ?: [], - glob("{$root}/src/packages/*/*.xml") ?: [], + SourceResolver::globSource($root, '*.xml'), + SourceResolver::globSource($root, 'packages/*/*.xml'), glob("{$root}/*.xml") ?: [] ); diff --git a/cli/package_build.php b/cli/package_build.php index c50ddf6..abf89a1 100644 --- a/cli/package_build.php +++ b/cli/package_build.php @@ -19,7 +19,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class PackageBuildCli extends CliFramework { @@ -56,18 +56,13 @@ class PackageBuildCli extends CliFramework } // -- Determine source directory ----------------------------------------------- - $sourceDir = null; - foreach (['src', 'htdocs'] as $candidate) { - if (is_dir("{$root}/{$candidate}")) { - $sourceDir = "{$root}/{$candidate}"; - break; - } - } + $sourceDir = SourceResolver::resolveAbsolute($root); if ($sourceDir === null) { - $this->log('ERROR', "No src/ or htdocs/ directory found in {$root}"); + $this->log('ERROR', "No source/ or src/ directory found in {$root}"); return 1; } + SourceResolver::warnIfLegacy($root); // -- Determine element and type prefix from manifest -------------------------- $extElement = $elementOverride; diff --git a/cli/release_create.php b/cli/release_create.php index 7c5a9e0..c7d6b61 100644 --- a/cli/release_create.php +++ b/cli/release_create.php @@ -17,7 +17,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class ReleaseCreateCli extends CliFramework { @@ -97,8 +97,8 @@ class ReleaseCreateCli extends CliFramework // Find extension manifest (Joomla XML) $extManifest = null; $manifestFiles = array_merge( - glob("{$root}/src/pkg_*.xml") ?: [], - glob("{$root}/src/*.xml") ?: [], + SourceResolver::globSource($root, 'pkg_*.xml'), + SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: [] ); foreach ($manifestFiles as $file) { @@ -112,8 +112,7 @@ class ReleaseCreateCli extends CliFramework // Find Dolibarr module file $modFile = null; $modFiles = array_merge( - glob("{$root}/src/core/modules/mod*.class.php") ?: [], - glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [], + SourceResolver::globSource($root, 'core/modules/mod*.class.php'), glob("{$root}/core/modules/mod*.class.php") ?: [] ); foreach ($modFiles as $file) { diff --git a/cli/release_package.php b/cli/release_package.php index 6b2d3df..2eb7035 100644 --- a/cli/release_package.php +++ b/cli/release_package.php @@ -17,7 +17,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class ReleasePackageCli extends CliFramework { @@ -99,9 +99,10 @@ class ReleasePackageCli extends CliFramework $extFolder = ''; $typePrefix = ''; + SourceResolver::warnIfLegacy($root); $manifestFiles = array_merge( - glob("{$root}/src/pkg_*.xml") ?: [], - glob("{$root}/src/*.xml") ?: [], + SourceResolver::globSource($root, 'pkg_*.xml'), + SourceResolver::globSource($root, '*.xml'), glob("{$root}/*.xml") ?: [] ); @@ -200,14 +201,12 @@ class ReleasePackageCli extends CliFramework } } - if ($sourceDir === null && is_dir("{$root}/src")) { - $sourceDir = "{$root}/src"; - } elseif ($sourceDir === null && is_dir("{$root}/htdocs")) { - $sourceDir = "{$root}/htdocs"; + if ($sourceDir === null) { + $sourceDir = SourceResolver::resolveAbsolute($root); } if ($sourceDir === null) { - echo "No src/ or htdocs/ directory found — skipping package build\n"; + echo "No source/ or src/ directory found — skipping package build\n"; return 0; } @@ -231,19 +230,20 @@ class ReleasePackageCli extends CliFramework $subZipPath = "{$outputDir}/{$subName}.zip"; // If sub-package is a full repo checkout (e.g. git submodule), - // look for a src/ subdirectory containing a Joomla manifest XML + // look for a source/ or src/ subdirectory containing a Joomla manifest XML // and zip that instead of the repo root. $subSourceDir = $pkgDir; - $srcCandidate = "{$pkgDir}/src"; - if (is_dir($srcCandidate)) { + $subSrcAbs = SourceResolver::resolveAbsolute($pkgDir); + if ($subSrcAbs !== null) { $srcManifests = array_merge( - glob("{$srcCandidate}/*.xml") ?: [], - glob("{$srcCandidate}/pkg_*.xml") ?: [] + glob("{$subSrcAbs}/*.xml") ?: [], + glob("{$subSrcAbs}/pkg_*.xml") ?: [] ); foreach ($srcManifests as $mf) { if (strpos(file_get_contents($mf) ?: '', 'addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory'); + $hasSource = SourceResolver::resolveAbsolute($root) !== null; + SourceResolver::warnIfLegacy($root); + $srcDirName = SourceResolver::resolve($root); + $this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? "{$srcDirName}/ found" : 'No source/ or src/ directory'); if (!file_exists("{$root}/README.md")) { $this->addVResult('README.md', 'FAIL', 'Not found'); } else { @@ -109,7 +111,8 @@ class ReleaseValidateCli extends CliFramework $this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); if ($platform === 'joomla') { $manifest = null; - foreach (["{$root}/src", $root] as $dir) { + $srcAbs = SourceResolver::resolveAbsolute($root); + foreach (array_filter([$srcAbs, $root]) as $dir) { if (!is_dir($dir)) { continue; } foreach (glob("{$dir}/*.xml") as $xmlFile) { @@ -156,7 +159,7 @@ class ReleaseValidateCli extends CliFramework } } elseif ($platform === 'dolibarr') { $modFile = null; - foreach (['src', 'htdocs'] as $sd) { + foreach (SourceResolver::getCandidates() as $sd) { $matches = glob("{$root}/{$sd}/mod*.class.php"); if (!empty($matches)) { $modFile = $matches[0]; diff --git a/cli/theme_lint.php b/cli/theme_lint.php index 748692a..aa910fb 100644 --- a/cli/theme_lint.php +++ b/cli/theme_lint.php @@ -17,7 +17,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class ThemeLintCli extends CliFramework { @@ -41,17 +41,12 @@ class ThemeLintCli extends CliFramework $errors = 0; $warnings = 0; - $srcDir = null; - foreach (['src', 'htdocs'] as $d) { - if (is_dir("{$root}/{$d}")) { - $srcDir = "{$root}/{$d}"; - break; - } - } + $srcDir = SourceResolver::resolveAbsolute($root); if ($srcDir === null) { - $this->log('ERROR', "No src/ or htdocs/ directory in {$root}"); + $this->log('ERROR', "No source/ or src/ directory in {$root}"); return 1; } + SourceResolver::warnIfLegacy($root); echo "Theme Lint: {$srcDir}\n\n"; diff --git a/cli/updates_xml_build.php b/cli/updates_xml_build.php index 2d9bd18..86f47d7 100644 --- a/cli/updates_xml_build.php +++ b/cli/updates_xml_build.php @@ -17,7 +17,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class UpdatesXmlBuildCli extends CliFramework { @@ -109,7 +109,7 @@ class UpdatesXmlBuildCli extends CliFramework // -- Locate Joomla manifest --------------------------------------------------- $manifest = null; - $candidates = glob("{$root}/src/pkg_*.xml") ?: []; + $candidates = SourceResolver::globSource($root, 'pkg_*.xml'); foreach ($candidates as $f) { if (strpos(file_get_contents($f), ' {$nextVersion} ({$branch})\n"; + // Try both source/ and src/ paths for backwards compatibility with remote repos $manifestPaths = []; - if ($manifestFile !== null) { - $manifestPaths[] = "src/{$manifestFile}"; + foreach (['source', 'src'] as $srcPrefix) { + if ($manifestFile !== null) { + $manifestPaths[] = "{$srcPrefix}/{$manifestFile}"; + } + $manifestPaths[] = "{$srcPrefix}/templateDetails.xml"; + $manifestPaths[] = "{$srcPrefix}/manifest.xml"; } - $manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']); $manifestUpdated = false; foreach ($manifestPaths as $mPath) { $result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string { diff --git a/cli/version_check.php b/cli/version_check.php index 3d00da6..5ac4f98 100644 --- a/cli/version_check.php +++ b/cli/version_check.php @@ -18,7 +18,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class VersionCheckCli extends CliFramework { @@ -77,7 +77,8 @@ class VersionCheckCli extends CliFramework $versions['pyproject.toml'] = $m[1]; } } - foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) { + $srcName = SourceResolver::resolve($root); + foreach (["{$root}/{$srcName}/pkg_*.xml", "{$root}/{$srcName}/*.xml", "{$root}/{$srcName}/packages/*/*.xml", "{$root}/*.xml"] as $glob) { foreach (glob($glob) ?: [] as $file) { if (basename($file) === 'updates.xml') { continue; diff --git a/cli/version_read.php b/cli/version_read.php index fa2865b..4971a26 100644 --- a/cli/version_read.php +++ b/cli/version_read.php @@ -17,7 +17,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class VersionReadCli extends CliFramework { @@ -64,9 +64,9 @@ class VersionReadCli extends CliFramework // -- 3. Fallback: Joomla manifest XML -- $manifestVersion = null; $manifestFiles = array_merge( - glob("{$root}/src/pkg_*.xml") ?: [], - glob("{$root}/src/*.xml") ?: [], - glob("{$root}/src/packages/*/*.xml") ?: [], + SourceResolver::globSource($root, 'pkg_*.xml'), + SourceResolver::globSource($root, '*.xml'), + SourceResolver::globSource($root, 'packages/*/*.xml'), glob("{$root}/*.xml") ?: [] ); diff --git a/cli/version_set_platform.php b/cli/version_set_platform.php index 8b3a66f..0133b8d 100644 --- a/cli/version_set_platform.php +++ b/cli/version_set_platform.php @@ -17,7 +17,7 @@ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; class VersionSetPlatformCli extends CliFramework { @@ -110,7 +110,8 @@ class VersionSetPlatformCli extends CliFramework // Dolibarr: $this->version + $this->url_last_version in mod*.class.php if ($platform === 'crm-module') { - $pattern = "{$root}/src/core/modules/mod*.class.php"; + $srcName = SourceResolver::resolve($root); + $pattern = "{$root}/{$srcName}/core/modules/mod*.class.php"; foreach (glob($pattern) ?: [] as $file) { $content = file_get_contents($file); @@ -146,9 +147,10 @@ class VersionSetPlatformCli extends CliFramework // Joomla: in XML manifests (top-level + sub-packages) if (in_array($platform, ['waas-component', 'joomla'], true)) { + $srcName = SourceResolver::resolve($root); $xmlFiles = array_merge( - glob("{$root}/src/*.xml") ?: [], - glob("{$root}/src/packages/*/*.xml") ?: [], + glob("{$root}/{$srcName}/*.xml") ?: [], + glob("{$root}/{$srcName}/packages/*/*.xml") ?: [], glob("{$root}/*.xml") ?: [] ); if (empty($xmlFiles)) { diff --git a/deploy/deploy-sftp.php b/deploy/deploy-sftp.php index 443bc16..d39492a 100644 --- a/deploy/deploy-sftp.php +++ b/deploy/deploy-sftp.php @@ -21,7 +21,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; use phpseclib3\Net\SFTP; use phpseclib3\Crypt\PublicKeyLoader; @@ -51,9 +51,9 @@ class DeploySftp extends CliFramework protected function configure(): void { - $this->setDescription('Deploy a repository src/ directory to a remote web server via SFTP'); + $this->setDescription('Deploy a repository source directory to a remote web server via SFTP'); $this->addArgument('--path', 'Repository root (default: current directory)', '.'); - $this->addArgument('--src-dir', 'Source sub-directory to upload (default: src)', 'src'); + $this->addArgument('--src-dir', 'Source sub-directory to upload (default: auto-detect)', ''); $this->addArgument('--env', 'Target environment: dev or rs', ''); $this->addArgument('--config', 'Explicit config file path — overrides --env', ''); $this->addArgument('--key-passphrase', 'Passphrase for the SSH private key', ''); @@ -158,7 +158,8 @@ class DeploySftp extends CliFramework */ private function resolveSrcDir(string $repoPath): string { - $sub = $this->getArgument('--src-dir', 'src'); + $sub = $this->getArgument('--src-dir', '') ?: SourceResolver::resolve($repoPath); + SourceResolver::warnIfLegacy($repoPath); $dir = $repoPath . DIRECTORY_SEPARATOR . $sub; if (!is_dir($dir)) { diff --git a/lib/Enterprise/ManifestReader.php b/lib/Enterprise/ManifestReader.php index 1631e20..f33d24e 100644 --- a/lib/Enterprise/ManifestReader.php +++ b/lib/Enterprise/ManifestReader.php @@ -147,31 +147,29 @@ class ManifestReader /** * Get the source/entry-point directory. * + * Fallback chain: manifest entry-point → source/ → src/ → htdocs/ → 'source'. + * Uses SourceResolver for the directory fallback when no entry-point is set. + * * @param string $root Repository root for existence checking - * @return string Resolved source directory path (e.g. 'src', 'htdocs') + * @return string Resolved source directory path (e.g. 'source', 'src', 'htdocs') */ public function getSourceDir(string $root = ''): string { $entryPoint = $this->get('entry-point', ''); if ($entryPoint !== '') { - // Strip trailing filename (e.g. src/index.ts → src) + // Strip trailing filename (e.g. source/index.ts → source) $dir = rtrim(dirname($entryPoint) === '.' ? $entryPoint : dirname($entryPoint), '/'); if ($root === '' || is_dir("{$root}/{$dir}")) { return $dir; } } - // Fallback: check common directories + // Fallback: use SourceResolver (source/ → src/ → htdocs/ → default 'source') if ($root !== '') { - if (is_dir("{$root}/src")) { - return 'src'; - } - if (is_dir("{$root}/htdocs")) { - return 'htdocs'; - } + return SourceResolver::resolve($root); } - return 'src'; + return 'source'; } /** diff --git a/lib/Enterprise/PackageBuilder.php b/lib/Enterprise/PackageBuilder.php index 46a05a3..e224389 100644 --- a/lib/Enterprise/PackageBuilder.php +++ b/lib/Enterprise/PackageBuilder.php @@ -68,7 +68,8 @@ class PackageBuilder mkdir($packageDir, 0755, true); mkdir($distDir, 0755, true); - foreach (['src', 'admin', 'site'] as $dir) { + $srcName = SourceResolver::resolve($repoRoot); + foreach ([$srcName, 'admin', 'site'] as $dir) { if (is_dir($repoRoot . '/' . $dir)) { self::copyDirectory($repoRoot . '/' . $dir, $packageDir . '/' . $dir); } @@ -94,15 +95,15 @@ class PackageBuilder /** * Build a Dolibarr module release package. * - * Copies everything under src/ into a build staging directory and archives - * it as dist/_.zip. + * Copies everything under source/ (or src/) into a build staging directory + * and archives it as dist/_.zip. * * @param string $repoRoot Absolute path to the repository root. * @param string $moduleName Module name (used in archive filename). * @param string $version Version string. * @param bool $dryRun When true, preview without writing. * @return string Path to the created archive (or would-create path in dry-run). - * @throws \RuntimeException When src/ is absent or archive creation fails. + * @throws \RuntimeException When source directory is absent or archive creation fails. */ public static function buildDolibarr( string $repoRoot, @@ -110,14 +111,15 @@ class PackageBuilder string $version, bool $dryRun = false ): string { - $srcDir = $repoRoot . '/src'; + $srcDir = SourceResolver::resolveAbsolute($repoRoot); $buildDir = $repoRoot . '/build'; $distDir = $repoRoot . '/dist'; $archivePath = $distDir . '/' . $moduleName . '_' . $version . '.zip'; - if (!is_dir($srcDir)) { - throw new \RuntimeException("src/ directory not found at {$srcDir}"); + if ($srcDir === null) { + throw new \RuntimeException("source/ or src/ directory not found in {$repoRoot}"); } + SourceResolver::warnIfLegacy($repoRoot); if ($dryRun) { return $archivePath; diff --git a/lib/Enterprise/Plugins/McpServerPlugin.php b/lib/Enterprise/Plugins/McpServerPlugin.php index 87f40e9..51c4a23 100644 --- a/lib/Enterprise/Plugins/McpServerPlugin.php +++ b/lib/Enterprise/Plugins/McpServerPlugin.php @@ -20,6 +20,7 @@ declare(strict_types=1); namespace MokoEnterprise\Plugins; use MokoEnterprise\AbstractProjectPlugin; +use MokoEnterprise\SourceResolver; /** * MCP Server Project Plugin @@ -55,10 +56,12 @@ class McpServerPlugin extends AbstractProjectPlugin $warnings = []; // Check for required source files - $requiredSrc = ['src/index.ts', 'src/client.ts', 'src/config.ts', 'src/types.ts']; + $srcName = SourceResolver::resolve($projectPath); + SourceResolver::warnIfLegacy($projectPath); + $requiredSrc = ['index.ts', 'client.ts', 'config.ts', 'types.ts']; foreach ($requiredSrc as $file) { - if (!file_exists("{$projectPath}/{$file}")) { - $errors[] = "Missing required source file: {$file}"; + if (SourceResolver::findUnderSource($projectPath, $file) === null) { + $errors[] = "Missing required source file: {$srcName}/{$file}"; } } @@ -82,37 +85,33 @@ class McpServerPlugin extends AbstractProjectPlugin $errors[] = 'Missing tsconfig.json'; } - // Check for setup wizard - if (!file_exists("{$projectPath}/scripts/setup.mjs")) { - $warnings[] = 'Missing scripts/setup.mjs — interactive setup wizard recommended'; - } - // Check for config example if (!file_exists("{$projectPath}/config.example.json")) { $warnings[] = 'Missing config.example.json — example configuration recommended'; } // Check for shebang in index.ts - if (file_exists("{$projectPath}/src/index.ts")) { - $content = @file_get_contents("{$projectPath}/src/index.ts"); + $indexTs = SourceResolver::findUnderSource($projectPath, 'index.ts'); + if ($indexTs !== null) { + $content = @file_get_contents($indexTs); if ($content && strpos($content, '#!/usr/bin/env node') === false) { - $warnings[] = 'src/index.ts should start with #!/usr/bin/env node shebang'; + $warnings[] = "{$srcName}/index.ts should start with #!/usr/bin/env node shebang"; } } // Check for McpServer usage - if (file_exists("{$projectPath}/src/index.ts")) { - $content = @file_get_contents("{$projectPath}/src/index.ts"); + if ($indexTs !== null) { + $content = $content ?? @file_get_contents($indexTs); if ($content && strpos($content, 'McpServer') === false) { - $errors[] = 'src/index.ts must import and use McpServer from @modelcontextprotocol/sdk'; + $errors[] = "{$srcName}/index.ts must import and use McpServer from @modelcontextprotocol/sdk"; } } // Check for StdioServerTransport - if (file_exists("{$projectPath}/src/index.ts")) { - $content = @file_get_contents("{$projectPath}/src/index.ts"); + if ($indexTs !== null) { + $content = $content ?? @file_get_contents($indexTs); if ($content && strpos($content, 'StdioServerTransport') === false) { - $warnings[] = 'src/index.ts should use StdioServerTransport for Claude Code compatibility'; + $warnings[] = "{$srcName}/index.ts should use StdioServerTransport for Claude Code compatibility"; } } @@ -190,12 +189,13 @@ class McpServerPlugin extends AbstractProjectPlugin $score = 100; // Check for required source files - $requiredSrc = ['src/index.ts', 'src/client.ts', 'src/config.ts', 'src/types.ts']; + $srcName = SourceResolver::resolve($projectPath); + $requiredSrc = ['index.ts', 'client.ts', 'config.ts', 'types.ts']; foreach ($requiredSrc as $file) { - if (!file_exists("{$projectPath}/{$file}")) { + if (SourceResolver::findUnderSource($projectPath, $file) === null) { $issues[] = [ 'severity' => 'critical', - 'message' => "Missing required file: {$file}", + 'message' => "Missing required file: {$srcName}/{$file}", ]; $score -= 20; } @@ -214,14 +214,15 @@ class McpServerPlugin extends AbstractProjectPlugin } // Check for at least one registered tool - if (file_exists("{$projectPath}/src/index.ts")) { - $content = @file_get_contents("{$projectPath}/src/index.ts"); + $indexTs = SourceResolver::findUnderSource($projectPath, 'index.ts'); + if ($indexTs !== null) { + $content = @file_get_contents($indexTs); if ($content) { $toolCount = substr_count($content, 'server.tool('); if ($toolCount === 0) { $issues[] = [ 'severity' => 'critical', - 'message' => 'No MCP tools registered in src/index.ts', + 'message' => "No MCP tools registered in {$srcName}/index.ts", ]; $score -= 25; } elseif ($toolCount < 5) { diff --git a/lib/Enterprise/RepositorySynchronizer.php b/lib/Enterprise/RepositorySynchronizer.php index 1be7584..e997434 100644 --- a/lib/Enterprise/RepositorySynchronizer.php +++ b/lib/Enterprise/RepositorySynchronizer.php @@ -1380,7 +1380,7 @@ class RepositorySynchronizer $descriptors = array_values(array_filter( $paths, - static fn(string $p): bool => (bool) preg_match('#src/core/modules/mod\w+\.class\.php$#', $p) + static fn(string $p): bool => (bool) preg_match('#(?:source|src)/core/modules/mod\w+\.class\.php$#', $p) )); if (empty($descriptors)) { diff --git a/lib/Enterprise/SourceResolver.php b/lib/Enterprise/SourceResolver.php new file mode 100644 index 0000000..c94d111 --- /dev/null +++ b/lib/Enterprise/SourceResolver.php @@ -0,0 +1,187 @@ + + * + * 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/moko-platform + * PATH: /lib/Enterprise/SourceResolver.php + * BRIEF: Resolve the root-level source directory across repos (source/, src/, htdocs/) + */ + +declare(strict_types=1); + +namespace MokoEnterprise; + +/** + * Source Directory Resolver + * + * Provides a single, consistent fallback chain for locating the root-level + * source directory in any MokoStandards 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']; + + /** + * Resolve the source directory name for a repository root. + * + * Returns the first candidate directory that exists, or 'source' as the + * default when no candidate is found (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 + { + foreach (self::CANDIDATES as $candidate) { + if (is_dir("{$root}/{$candidate}")) { + return $candidate; + } + } + + return 'source'; + } + + /** + * 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 MokoStandards.\n"); + } + } +} diff --git a/validate/auto_detect_platform.php b/validate/auto_detect_platform.php index 0760b21..00fd953 100755 --- a/validate/auto_detect_platform.php +++ b/validate/auto_detect_platform.php @@ -27,7 +27,8 @@ use MokoEnterprise\{ PluginFactory, PluginRegistry, AuditLogger, - MetricsCollector + MetricsCollector, + SourceResolver }; /** @@ -228,8 +229,9 @@ class AutoDetectPlatform extends CliFramework } } - // Legacy: site structure inside src/ - $siteDirs = ['src/administrator', 'src/components', 'src/plugins', 'src/templates', 'src/media']; + // Legacy: site structure inside source/ or src/ + $srcName = SourceResolver::resolve($repoPath); + $siteDirs = ["{$srcName}/administrator", "{$srcName}/components", "{$srcName}/plugins", "{$srcName}/templates", "{$srcName}/media"]; $siteDirCount = 0; foreach ($siteDirs as $dir) { if (is_dir($repoPath . '/' . $dir)) { @@ -238,7 +240,7 @@ class AutoDetectPlatform extends CliFramework } if ($siteDirCount >= 3) { $score += 20; - $indicators[] = "Joomla site structure in src/ ({$siteDirCount}/5 dirs)"; + $indicators[] = "Joomla site structure in {$srcName}/ ({$siteDirCount}/5 dirs)"; } // Negative: if there's a Joomla extension manifest (not type="file"), it's an extension @@ -710,17 +712,19 @@ class AutoDetectPlatform extends CliFramework } // Check for MCP server entry point with McpServer usage - if (file_exists("{$repoPath}/src/index.ts")) { - $content = @file_get_contents("{$repoPath}/src/index.ts"); + $mcpEntry = SourceResolver::findUnderSource($repoPath, 'index.ts'); + if ($mcpEntry !== null) { + $content = @file_get_contents($mcpEntry); + $mcpSrcName = SourceResolver::resolve($repoPath); if ($content) { if (strpos($content, 'McpServer') !== false) { $score += 0.3; - $indicators[] = "Found McpServer import in src/index.ts"; + $indicators[] = "Found McpServer import in {$mcpSrcName}/index.ts"; } if (strpos($content, 'server.tool(') !== false) { $score += 0.1; $toolCount = substr_count($content, 'server.tool('); - $indicators[] = "Found {$toolCount} tool registrations in src/index.ts"; + $indicators[] = "Found {$toolCount} tool registrations in {$mcpSrcName}/index.ts"; } if (strpos($content, 'StdioServerTransport') !== false) { $score += 0.1; @@ -730,16 +734,17 @@ class AutoDetectPlatform extends CliFramework } // Check for the standard 4-file MCP structure - $mcpFiles = ['src/index.ts', 'src/client.ts', 'src/config.ts', 'src/types.ts']; + $mcpRequired = ['index.ts', 'client.ts', 'config.ts', 'types.ts']; $foundCount = 0; - foreach ($mcpFiles as $file) { - if (file_exists("{$repoPath}/{$file}")) { + foreach ($mcpRequired as $file) { + if (SourceResolver::findUnderSource($repoPath, $file) !== null) { $foundCount++; } } if ($foundCount === 4) { $score += 0.1; - $indicators[] = "Found standard MCP 4-file src/ structure"; + $mcpSrcName = $mcpSrcName ?? SourceResolver::resolve($repoPath); + $indicators[] = "Found standard MCP 4-file {$mcpSrcName}/ structure"; } // Check for setup wizard diff --git a/validate/check_changelog.php b/validate/check_changelog.php index 7170adc..221f694 100644 --- a/validate/check_changelog.php +++ b/validate/check_changelog.php @@ -30,7 +30,7 @@ use MokoEnterprise\CliFramework; class CheckChangelog extends CliFramework { /** Directories searched for CHANGELOG.md, relative to --path (case-insensitive match). */ - private const SEARCH_DIRS = ['', 'src', 'docs']; + private const SEARCH_DIRS = ['', 'source', 'src', 'docs']; /** * Configure available arguments. @@ -57,7 +57,7 @@ class CheckChangelog extends CliFramework $found = $this->findChangelog($path); if ($found === null) { - $this->status(false, 'CHANGELOG.md found (checked root, src/, docs/)'); + $this->status(false, 'CHANGELOG.md found (checked root, source/, src/, docs/)'); $this->printSummary(0, 1, $this->elapsed()); return 1; } diff --git a/validate/check_client_theme.php b/validate/check_client_theme.php index b399fe5..984f14f 100644 --- a/validate/check_client_theme.php +++ b/validate/check_client_theme.php @@ -19,7 +19,7 @@ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; /** * Validates client theme packages that deliver CSS, JS, and images @@ -44,17 +44,17 @@ class CheckClientTheme extends CliFramework /** Recommended XML elements. */ private const RECOMMENDED_ELEMENTS = ['updateservers', 'scriptfile', 'description', 'fileset']; - /** Required theme CSS files relative to repo root. */ + /** Required theme CSS files relative to the source directory. */ private const REQUIRED_THEME_FILES = [ - 'src/media/templates/site/mokoonyx/css/theme/light.custom.css', - 'src/media/templates/site/mokoonyx/css/theme/dark.custom.css', + 'media/templates/site/mokoonyx/css/theme/light.custom.css', + 'media/templates/site/mokoonyx/css/theme/dark.custom.css', ]; - /** Optional but expected files. */ + /** Optional but expected files (paths prefixed with ~ are relative to source dir). */ private const EXPECTED_FILES = [ - 'src/media/templates/site/mokoonyx/css/user.css', - 'src/media/templates/site/mokoonyx/js/user.js', - 'src/script.php', + '~media/templates/site/mokoonyx/css/user.css', + '~media/templates/site/mokoonyx/js/user.js', + '~script.php', 'updates.xml', ]; @@ -81,10 +81,12 @@ class CheckClientTheme extends CliFramework // ── Manifest ────────────────────────────────────────── $this->section('Manifest validation'); - $manifest = $path . '/src/templateDetails.xml'; + $srcName = SourceResolver::resolve($path); + SourceResolver::warnIfLegacy($path); + $manifest = $path . "/{$srcName}/templateDetails.xml"; if (!is_file($manifest)) { - $this->status(false, 'Missing src/templateDetails.xml'); + $this->status(false, "Missing {$srcName}/templateDetails.xml"); $this->printSummary(0, 1, $this->elapsed()); return 1; } @@ -144,28 +146,36 @@ class CheckClientTheme extends CliFramework // ── Required files ──────────────────────────────────── $this->section('Required files'); foreach (self::REQUIRED_THEME_FILES as $file) { - $full = $path . '/' . $file; + $full = "{$path}/{$srcName}/{$file}"; if (is_file($full)) { $this->status(true, basename($file)); } else { - $this->status(false, "Missing: {$file}"); + $this->status(false, "Missing: {$srcName}/{$file}"); $errors++; } } foreach (self::EXPECTED_FILES as $file) { - $full = $path . '/' . $file; + // Paths prefixed with ~ are relative to source dir + if (str_starts_with($file, '~')) { + $relFile = substr($file, 1); + $full = "{$path}/{$srcName}/{$relFile}"; + $display = "{$srcName}/{$relFile}"; + } else { + $full = "{$path}/{$file}"; + $display = $file; + } if (is_file($full)) { $this->status(true, basename($file)); } else { - $this->warning("Missing: {$file}"); + $this->warning("Missing: {$display}"); $warns++; } } // ── PHP syntax ──────────────────────────────────────── $this->section('PHP syntax'); - $phpFiles = glob($path . '/src/*.php') ?: []; + $phpFiles = glob("{$path}/{$srcName}/*.php") ?: []; foreach ($phpFiles as $phpFile) { $output = []; $ret = 0; @@ -179,20 +189,20 @@ class CheckClientTheme extends CliFramework } } if (empty($phpFiles)) { - $this->warning('No PHP files in src/'); + $this->warning("No PHP files in {$srcName}/"); } // ── CSS validation ──────────────────────────────────── $this->section('CSS validation'); $cssFiles = array_merge( - glob($path . '/src/media/templates/site/mokoonyx/css/theme/*.css') ?: [], - glob($path . '/src/media/templates/site/mokoonyx/css/*.css') ?: [], + glob("{$path}/{$srcName}/media/templates/site/mokoonyx/css/theme/*.css") ?: [], + glob("{$path}/{$srcName}/media/templates/site/mokoonyx/css/*.css") ?: [], ); foreach ($cssFiles as $cssFile) { $css = (string) file_get_contents($cssFile); $open = substr_count($css, '{'); $close = substr_count($css, '}'); - $name = str_replace($path . '/src/', '', $cssFile); + $name = str_replace("{$path}/{$srcName}/", '', $cssFile); if ($open !== $close) { $this->status(false, "Unbalanced braces in {$name} (open: {$open}, close: {$close})"); @@ -241,7 +251,7 @@ class CheckClientTheme extends CliFramework // ── Image sizes ─────────────────────────────────────── $this->section('Image optimization'); $largeImages = 0; - $imageDir = $path . '/src/images'; + $imageDir = "{$path}/{$srcName}/images"; if (is_dir($imageDir)) { $iter = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($imageDir, \FilesystemIterator::SKIP_DOTS) diff --git a/validate/check_dolibarr_module.php b/validate/check_dolibarr_module.php index 59c2803..edcc9ae 100644 --- a/validate/check_dolibarr_module.php +++ b/validate/check_dolibarr_module.php @@ -19,7 +19,7 @@ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; -use MokoEnterprise\CliFramework; +use MokoEnterprise\{CliFramework, SourceResolver}; /** * Validates the required directory structure of a Dolibarr module repository. @@ -47,33 +47,36 @@ class CheckDolibarrModule extends CliFramework $failed = 0; $this->section('Checking directory structure'); + $srcName = SourceResolver::resolve($path); + SourceResolver::warnIfLegacy($path); - if (!is_dir($path . '/src')) { - $this->status(false, 'src/ directory exists'); + $srcDir = SourceResolver::resolveAbsolute($path); + if ($srcDir === null) { + $this->status(false, 'source/ or src/ directory exists'); $failed++; } else { - $this->status(true, 'src/ directory exists'); + $this->status(true, "{$srcName}/ directory exists"); $passed++; } - if (!is_dir($path . '/src/core/modules')) { - $this->status(false, 'src/core/modules/ directory exists'); + if (!is_dir($path . "/{$srcName}/core/modules")) { + $this->status(false, "{$srcName}/core/modules/ directory exists"); $failed++; } else { - $this->status(true, 'src/core/modules/ directory exists'); + $this->status(true, "{$srcName}/core/modules/ directory exists"); $passed++; } - if (!is_dir($path . '/src/langs')) { - $this->warning('Missing suggested directory: src/langs/'); + if (!is_dir($path . "/{$srcName}/langs")) { + $this->warning("Missing suggested directory: {$srcName}/langs/"); } else { - $this->status(true, 'src/langs/ directory exists'); + $this->status(true, "{$srcName}/langs/ directory exists"); $passed++; } $this->section('Checking module descriptor'); - $descriptors = glob($path . '/src/core/modules/mod*.class.php') ?: []; + $descriptors = glob($path . "/{$srcName}/core/modules/mod*.class.php") ?: []; if (empty($descriptors)) { $this->status(false, 'Module descriptor found (mod*.class.php)'); $failed++; diff --git a/validate/check_structure.php b/validate/check_structure.php index cd14d58..f4f9f6b 100644 --- a/validate/check_structure.php +++ b/validate/check_structure.php @@ -37,7 +37,7 @@ class CheckStructure extends CliFramework private const REQUIRED_FILES = ['README.md', 'LICENSE', 'CONTRIBUTING.md', 'SECURITY.md']; /** Directories searched for CHANGELOG.md (case-insensitive), relative to repo root. */ - private const CHANGELOG_DIRS = ['', 'src', 'docs']; + private const CHANGELOG_DIRS = ['', 'source', 'src', 'docs']; /** * Configure available arguments.