* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Enterprise * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /lib/Enterprise/MokoStandardsParser.php * BRIEF: Parser for the XML-based .mokostandards repository manifest */ declare(strict_types=1); namespace MokoEnterprise; use DOMDocument; use SimpleXMLElement; /** * MokoStandards Parser * * Reads, writes, and validates the .mokostandards repository manifest. * The file uses XML format (no file extension) and lives at .mokogitea/.mokostandards. * * @package MokoStandards\Enterprise * @version 04.07.00 */ class MokoStandardsParser { public const SCHEMA_VERSION = '1.0'; public const NAMESPACE_URI = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API'; public const STANDARDS_SOURCE = 'https://git.mokoconsulting.tech/MokoConsulting/MokoStandards'; /** Valid platform slugs — must match definitions/default/*.tf filenames. */ public const VALID_PLATFORMS = [ 'default-repository', 'crm-module', 'crm-platform', 'generic-repository', 'github-private-repository', 'joomla-template', 'standards-repository', 'waas-component', ]; /** * Parse a .mokostandards XML string into a structured array. * * @param string $xmlContent Raw XML content of .mokostandards * @return array{ * identity: array{name: string, org: string, description?: string, license?: string, license_spdx?: string, topics?: list}, * governance: array{platform: string, standards_version: string, standards_source: string, last_synced?: string}, * build?: array, * deploy?: array, * scripts?: array, * overrides?: array * } * @throws \RuntimeException If XML is invalid or missing required elements */ public function parse(string $xmlContent): array { libxml_use_internal_errors(true); $xml = simplexml_load_string($xmlContent); if ($xml === false) { $errors = libxml_get_errors(); libxml_clear_errors(); $msg = !empty($errors) ? $errors[0]->message : 'Unknown XML parse error'; throw new \RuntimeException("Invalid .mokostandards XML: " . trim($msg)); } // Register namespace for XPath $xml->registerXPathNamespace('m', self::NAMESPACE_URI); $result = [ 'schema_version' => (string) ($xml['schema-version'] ?? self::SCHEMA_VERSION), 'identity' => $this->parseIdentity($xml), 'governance' => $this->parseGovernance($xml), ]; if (isset($xml->build)) { $result['build'] = $this->parseBuild($xml->build); } if (isset($xml->deploy)) { $result['deploy'] = $this->parseDeploy($xml->deploy); } if (isset($xml->scripts)) { $result['scripts'] = $this->parseScripts($xml->scripts); } if (isset($xml->overrides)) { $result['overrides'] = $this->parseOverrides($xml->overrides); } return $result; } /** * Try to parse content, returning null on failure instead of throwing. * * @param string $content Raw file content (XML or legacy YAML-like) * @return array|null Parsed data or null if unparseable */ public function tryParse(string $content): ?array { // Try XML first if (str_contains($content, 'parse($content); } catch (\RuntimeException $e) { return null; } } // Try legacy YAML-like format (e.g. "platform: default-repository") return $this->parseLegacy($content); } /** * Parse the legacy single-line YAML-like format. * * @param string $content e.g. "platform: default-repository\n" * @return array|null Minimal parsed structure or null */ public function parseLegacy(string $content): ?array { $platform = null; $fields = []; foreach (explode("\n", $content) as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '#')) { continue; } if (preg_match('/^(\w[\w_-]*)\s*:\s*"?([^"]*)"?\s*$/', $line, $m)) { $fields[$m[1]] = $m[2]; } } $platform = $fields['platform'] ?? null; if ($platform === null) { return null; } return [ 'schema_version' => '0.0', // legacy marker 'identity' => [ 'name' => $fields['governed_repo'] ?? '', 'org' => '', ], 'governance' => [ 'platform' => $platform, 'standards_version' => $fields['standards_version'] ?? '', 'standards_source' => $fields['standards_source'] ?? '', ], ]; } /** * Extract just the platform slug from any .mokostandards content (XML or legacy). * * @param string $content Raw file content * @return string|null Platform slug or null if unreadable */ public function extractPlatform(string $content): ?string { $data = $this->tryParse($content); return $data['governance']['platform'] ?? null; } /** * Generate XML .mokostandards content for a repository. * * @param array $params { * @type string $name Repository name (required) * @type string $org Organization (required) * @type string $platform Platform slug (required) * @type string $standards_version MokoStandards version * @type string $description Repo description * @type string $license SPDX license identifier * @type list $topics Repo topics * @type string $language Primary language * @type string $runtime Runtime requirement * @type string $package_type Package format * @type string $entry_point Main entry file * @type string $last_synced ISO 8601 timestamp * } * @return string Well-formed XML content */ public function generate(array $params): string { $name = $params['name'] ?? ''; $org = $params['org'] ?? ''; $platform = $params['platform'] ?? 'default-repository'; $version = $params['standards_version'] ?? ''; $dom = new DOMDocument('1.0', 'UTF-8'); $dom->formatOutput = true; // Add comment header $dom->appendChild($dom->createComment( "\n MokoStandards Repository Manifest\n" . " Auto-generated by MokoStandards bulk sync.\n" . " Manual edits to and may be overwritten.\n" . " See: docs/standards/mokostandards-file-spec.md\n" )); // Root element $root = $dom->createElementNS(self::NAMESPACE_URI, 'mokostandards'); $root->setAttribute('schema-version', self::SCHEMA_VERSION); $dom->appendChild($root); // $identity = $dom->createElement('identity'); $identity->appendChild($dom->createElement('name', $this->xmlEscape($name))); $identity->appendChild($dom->createElement('org', $this->xmlEscape($org))); if (!empty($params['description'])) { $identity->appendChild($dom->createElement('description', $this->xmlEscape($params['description']))); } if (!empty($params['license'])) { $license = $dom->createElement('license', $this->xmlEscape($this->licenseLabel($params['license']))); $license->setAttribute('spdx', $params['license']); $identity->appendChild($license); } if (!empty($params['topics'])) { $topics = $dom->createElement('topics'); foreach ($params['topics'] as $topic) { $topics->appendChild($dom->createElement('topic', $this->xmlEscape($topic))); } $identity->appendChild($topics); } $root->appendChild($identity); // $governance = $dom->createElement('governance'); $governance->appendChild($dom->createElement('platform', $this->xmlEscape($platform))); $governance->appendChild($dom->createElement('standards-version', $this->xmlEscape($version))); $governance->appendChild($dom->createElement('standards-source', self::STANDARDS_SOURCE)); if (!empty($params['last_synced'])) { $governance->appendChild($dom->createElement('last-synced', $params['last_synced'])); } $root->appendChild($governance); // (optional) if (!empty($params['language']) || !empty($params['runtime']) || !empty($params['package_type']) || !empty($params['entry_point'])) { $build = $dom->createElement('build'); if (!empty($params['language'])) { $build->appendChild($dom->createElement('language', $this->xmlEscape($params['language']))); } if (!empty($params['runtime'])) { $build->appendChild($dom->createElement('runtime', $this->xmlEscape($params['runtime']))); } if (!empty($params['package_type'])) { $build->appendChild($dom->createElement('package-type', $this->xmlEscape($params['package_type']))); } if (!empty($params['entry_point'])) { $build->appendChild($dom->createElement('entry-point', $this->xmlEscape($params['entry_point']))); } $root->appendChild($build); } return $dom->saveXML(); } /** * Validate XML content against the XSD schema. * * @param string $xmlContent Raw XML content * @param string|null $xsdPath Path to the XSD file (auto-detected if null) * @return array{valid: bool, errors: list} */ public function validate(string $xmlContent, ?string $xsdPath = null): array { if ($xsdPath === null) { $xsdPath = dirname(dirname(__DIR__)) . '/templates/schemas/mokostandards-schema.xsd'; } if (!file_exists($xsdPath)) { return ['valid' => false, 'errors' => ["XSD schema not found: {$xsdPath}"]]; } libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadXML($xmlContent); $valid = $dom->schemaValidate($xsdPath); $errors = []; if (!$valid) { foreach (libxml_get_errors() as $error) { $errors[] = "Line {$error->line}: " . trim($error->message); } } libxml_clear_errors(); return ['valid' => $valid, 'errors' => $errors]; } // ────────────────────────────────────────────────────────────── // Private parsing helpers // ────────────────────────────────────────────────────────────── private function parseIdentity(SimpleXMLElement $xml): array { $id = $xml->identity ?? null; if ($id === null) { throw new \RuntimeException('.mokostandards: missing required element'); } $result = [ 'name' => (string) ($id->name ?? ''), 'org' => (string) ($id->org ?? ''), ]; if ($result['name'] === '') { throw new \RuntimeException('.mokostandards: is required'); } if (isset($id->description)) { $result['description'] = (string) $id->description; } if (isset($id->license)) { $result['license'] = (string) $id->license; $spdx = (string) ($id->license['spdx'] ?? ''); if ($spdx !== '') { $result['license_spdx'] = $spdx; } } if (isset($id->topics)) { $result['topics'] = []; foreach ($id->topics->topic as $topic) { $result['topics'][] = (string) $topic; } } return $result; } private function parseGovernance(SimpleXMLElement $xml): array { $gov = $xml->governance ?? null; if ($gov === null) { throw new \RuntimeException('.mokostandards: missing required element'); } $result = [ 'platform' => (string) ($gov->platform ?? ''), 'standards_version' => (string) ($gov->{'standards-version'} ?? ''), 'standards_source' => (string) ($gov->{'standards-source'} ?? ''), ]; if ($result['platform'] === '') { throw new \RuntimeException('.mokostandards: is required'); } if (isset($gov->{'last-synced'})) { $result['last_synced'] = (string) $gov->{'last-synced'}; } return $result; } private function parseBuild(SimpleXMLElement $build): array { $result = []; foreach (['language', 'runtime', 'entry-point'] as $field) { if (isset($build->$field)) { $key = str_replace('-', '_', $field); $result[$key] = (string) $build->$field; } } if (isset($build->{'package-type'})) { $result['package_type'] = (string) $build->{'package-type'}; } if (isset($build->artifact)) { $result['artifact'] = []; foreach (['format', 'path', 'filename'] as $f) { if (isset($build->artifact->$f)) { $result['artifact'][$f] = (string) $build->artifact->$f; } } } if (isset($build->dependencies)) { $result['dependencies'] = []; foreach ($build->dependencies->requires as $req) { $dep = ['name' => (string) ($req['name'] ?? '')]; if (isset($req['version'])) { $dep['version'] = (string) $req['version']; } if (isset($req['type'])) { $dep['type'] = (string) $req['type']; } $result['dependencies'][] = $dep; } } return $result; } private function parseDeploy(SimpleXMLElement $deploy): array { $targets = []; foreach ($deploy->target as $target) { $t = [ 'name' => (string) ($target['name'] ?? ''), 'enabled' => ((string) ($target['enabled'] ?? 'true')) !== 'false', 'host' => (string) ($target->host ?? ''), 'path' => (string) ($target->path ?? ''), ]; if (isset($target->method)) { $t['method'] = (string) $target->method; } if (isset($target->branch)) { $t['branch'] = (string) $target->branch; } if (isset($target->{'src-dir'})) { $t['src_dir'] = (string) $target->{'src-dir'}; } $targets[] = $t; } return ['targets' => $targets]; } private function parseScripts(SimpleXMLElement $scripts): array { $result = []; foreach ($scripts->script as $script) { $s = [ 'name' => (string) ($script['name'] ?? ''), 'command' => (string) ($script->command ?? ''), ]; if (isset($script['phase'])) { $s['phase'] = (string) $script['phase']; } if (isset($script->description)) { $s['description'] = (string) $script->description; } if (isset($script->runner)) { $s['runner'] = (string) $script->runner; } $result[] = $s; } return ['scripts' => $result]; } private function parseOverrides(SimpleXMLElement $overrides): array { $result = []; if (isset($overrides->{'skip-files'})) { $result['skip_files'] = []; foreach ($overrides->{'skip-files'}->file as $file) { $result['skip_files'][] = (string) $file; } } if (isset($overrides->{'skip-workflows'})) { $result['skip_workflows'] = []; foreach ($overrides->{'skip-workflows'}->file as $file) { $result['skip_workflows'][] = (string) $file; } } if (isset($overrides->{'extra-secrets'})) { $result['extra_secrets'] = []; foreach ($overrides->{'extra-secrets'}->secret as $secret) { $s = ['name' => (string) ($secret['name'] ?? '')]; if (isset($secret['required'])) { $s['required'] = ((string) $secret['required']) !== 'false'; } if (isset($secret['scope'])) { $s['scope'] = (string) $secret['scope']; } $result['extra_secrets'][] = $s; } } return $result; } /** * Escape a string for XML element content. */ private function xmlEscape(string $value): string { return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8'); } /** * Map SPDX identifier to a human-readable license label. */ private function licenseLabel(string $spdx): string { return match ($spdx) { 'GPL-3.0-or-later' => 'GNU General Public License v3', 'GPL-2.0-or-later' => 'GNU General Public License v2', 'MIT' => 'MIT License', 'Apache-2.0' => 'Apache License 2.0', 'BSD-3-Clause' => 'BSD 3-Clause License', 'LGPL-3.0-or-later' => 'GNU Lesser General Public License v3', default => $spdx, }; } /** * Map a platform slug to its default package type. */ public static function platformPackageType(string $platform): string { return match ($platform) { 'crm-module', 'crm-platform' => 'dolibarr-module', 'waas-component' => 'joomla-extension', 'joomla-template' => 'joomla-extension', 'standards-repository' => 'composer', default => 'composer', }; } /** * Map a platform slug to its default primary language. */ public static function platformLanguage(string $platform): string { return match ($platform) { 'crm-module', 'crm-platform' => 'PHP', 'waas-component', 'joomla-template' => 'PHP', 'standards-repository' => 'PHP', default => 'PHP', }; } }