4cc3f5bee4
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 14s
Generic: Repo Health / Repository health (pull_request) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 49s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
- Convert tabs to spaces (3,413 violations) - Fix line endings, trailing whitespace, brace placement - Break lines exceeding 150-char absolute limit - Replace heredoc tab closers with spaces - Fix empty elseif, forbidden function calls - Update phpcs.xml: exclude rules inappropriate for CLI scripts (SideEffects, MissingNamespace, MultipleClasses, HeaderOrder, empty catch blocks) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
547 lines
19 KiB
PHP
547 lines
19 KiB
PHP
<?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: 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<string>},
|
|
* 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, '<mokostandards')) {
|
|
try {
|
|
return $this->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<string> $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 <governance> and <last-synced> 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>
|
|
$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>
|
|
$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);
|
|
|
|
// <build> (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<string>}
|
|
*/
|
|
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 <identity> element');
|
|
}
|
|
|
|
$result = [
|
|
'name' => (string) ($id->name ?? ''),
|
|
'org' => (string) ($id->org ?? ''),
|
|
];
|
|
|
|
if ($result['name'] === '') {
|
|
throw new \RuntimeException('.mokostandards: <identity><name> 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 <governance> 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: <governance><platform> 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',
|
|
};
|
|
}
|
|
}
|