Files
Jonathan Miller 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
style: fix all PHPCS PSR-12 violations across 74 files (7539 → 0 errors)
- 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>
2026-05-24 17:07:51 -05:00

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',
};
}
}