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

500 lines
18 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/DefinitionParser.php
* BRIEF: Parses Terraform HCL repository definition files into a flat sync-file list
*/
declare(strict_types=1);
namespace MokoEnterprise;
/**
* Definition Parser
*
* Parses the Terraform HCL repository definition files stored in
* definitions/default/ and returns a flat list of file sync entries.
*
* File blocks that carry either a `template` field (external file path) or a
* `stub_content` heredoc (inline content) are returned — these are the files
* that the bulk-sync process should push to remote repositories.
*
* When both `stub_content` and `template` are present in the same block,
* `stub_content` takes priority (the definition file is authoritative).
*
* Each returned entry is an associative array with one of two shapes:
*
* External-file entry (legacy, uses `template` path):
* 'source' => string — path relative to the MokoStandards repo root
* 'destination' => string — path in the target repository
* 'always_overwrite' => bool — true: overwrite existing file; false: create-only
*
* Inline-content entry (uses `stub_content` heredoc):
* 'inline_content' => string — rendered template content (ready to push)
* 'destination' => string — path in the target repository
* 'always_overwrite' => bool — true: overwrite existing file; false: create-only
*/
class DefinitionParser
{
/** Map platform slug → definition file basename */
private const PLATFORM_DEFINITION_MAP = [
'crm-module' => 'crm-module.tf',
'waas-component' => 'waas-component.tf',
'generic-repository' => 'generic-repository.tf',
'default-repository' => 'default-repository.tf',
'standards' => 'standards-repository.tf',
];
/** Default definition used when platform has no specific file */
private const FALLBACK_DEFINITION = 'default-repository.tf';
/** Directory containing the base definition files */
private const DEFINITIONS_DIR = 'definitions/default';
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
/**
* Parse a definition file by platform slug.
*
* @param string $platform e.g. 'crm-module', 'waas-component'
* @param string $repoRoot Absolute path to the MokoStandards repository root
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parseForPlatform(string $platform, string $repoRoot): array
{
$basename = self::PLATFORM_DEFINITION_MAP[$platform] ?? self::FALLBACK_DEFINITION;
$path = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . $basename;
if (!file_exists($path)) {
$fallback = rtrim($repoRoot, '/') . '/' . self::DEFINITIONS_DIR . '/' . self::FALLBACK_DEFINITION;
if (!file_exists($fallback)) {
return [];
}
$path = $fallback;
}
return $this->parseFile($path);
}
/**
* Parse a definition file at an explicit filesystem path.
*
* @param string $filePath Absolute path to the .tf definition file
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parseFile(string $filePath): array
{
if (!file_exists($filePath)) {
return [];
}
$content = file_get_contents($filePath);
if ($content === false) {
return [];
}
return $this->parse($content);
}
/**
* Parse raw HCL content.
*
* @param string $content Raw .tf file content
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
public function parse(string $content): array
{
$entries = [];
// root_files = [ { ... }, ... ]
$rootFilesContent = $this->extractNamedArray($content, 'root_files');
if ($rootFilesContent !== null) {
$entries = array_merge($entries, $this->parseFileBlocks($rootFilesContent, ''));
}
// directories = [ { ... }, ... ]
$dirsContent = $this->extractNamedArray($content, 'directories');
if ($dirsContent !== null) {
$entries = array_merge($entries, $this->parseDirectories($dirsContent));
}
return $entries;
}
// -----------------------------------------------------------------------
// Internal parsing helpers
// -----------------------------------------------------------------------
/**
* Locate `name = [` inside $content and return the content between the
* outermost `[` and its matching `]`, or null if not found.
*/
private function extractNamedArray(string $content, string $name): ?string
{
$pattern = '/\b' . preg_quote($name, '/') . '\s*=\s*\[/';
// Build a mask of heredoc regions so the regex doesn't match inside them.
// Replace heredoc content with spaces (preserving offsets) before matching.
$masked = $content;
$len = strlen($content);
$i = 0;
while ($i < $len - 1) {
if ($content[$i] === '<' && $content[$i + 1] === '<') {
$heredocEnd = $this->skipHeredoc($content, $i, $len);
// Blank out the heredoc region in the masked copy
for ($k = $i; $k < $heredocEnd && $k < $len; $k++) {
$masked[$k] = ($content[$k] === "\n") ? "\n" : ' ';
}
$i = $heredocEnd;
continue;
}
$i++;
}
if (!preg_match($pattern, $masked, $match, PREG_OFFSET_CAPTURE)) {
return null;
}
// Position of the `[` at the end of the matched string — use original content
$openPos = $match[0][1] + strlen($match[0][0]) - 1;
return $this->extractBetweenPair($content, $openPos, '[', ']');
}
/**
* Starting at $pos (which must hold $open), walk forward counting depth
* until the matching $close is found. Returns the content between them
* (exclusive), or null on malformed input.
*/
private function extractBetweenPair(string $content, int $pos, string $open, string $close): ?string
{
if (!isset($content[$pos]) || $content[$pos] !== $open) {
return null;
}
$depth = 0;
$start = $pos;
$len = strlen($content);
for ($i = $pos; $i < $len; $i++) {
// Skip heredoc regions — they contain unbalanced brackets in markdown/code
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
$i = $this->skipHeredoc($content, $i, $len) - 1; // -1 because for loop increments
continue;
}
if ($content[$i] === $open) {
$depth++;
} elseif ($content[$i] === $close) {
$depth--;
if ($depth === 0) {
return substr($content, $start + 1, $i - $start - 1);
}
}
}
return null; // unterminated
}
/**
* Split $content into top-level `{ … }` blocks (depth 1 only).
*
* Heredoc sections (`<<-WORD … WORD` and `<<WORD … WORD`) are skipped in
* their entirety so that any `{` or `}` characters inside template content
* do not corrupt the brace-depth counter.
*
* @return string[] Each element is the inner content of one block (without outer braces)
*/
private function splitBlocks(string $content): array
{
$blocks = [];
$depth = 0;
$start = null;
$len = strlen($content);
$i = 0;
while ($i < $len) {
// Detect heredoc: <<WORD or <<-WORD
if ($content[$i] === '<' && isset($content[$i + 1]) && $content[$i + 1] === '<') {
$i = $this->skipHeredoc($content, $i, $len);
continue;
}
if ($content[$i] === '{') {
if ($depth === 0) {
$start = $i;
}
$depth++;
} elseif ($content[$i] === '}') {
$depth--;
if ($depth === 0 && $start !== null) {
$blocks[] = substr($content, $start + 1, $i - $start - 1);
$start = null;
}
}
$i++;
}
return $blocks;
}
/**
* Advance past a HCL heredoc starting at position $i.
*
* Supports both `<<WORD` (content-preserving) and `<<-WORD`
* (indent-stripping) forms. Returns the index immediately after the
* closing delimiter line, or $i + 2 if the heredoc is malformed.
*/
private function skipHeredoc(string $content, int $i, int $len): int
{
$j = $i + 2; // skip <<
// Optional indent-strip marker
$stripIndent = false;
if (isset($content[$j]) && $content[$j] === '-') {
$stripIndent = true;
$j++;
}
// Read the delimiter identifier (alphanumeric + underscore)
$delimiter = '';
while ($j < $len && (ctype_alnum($content[$j]) || $content[$j] === '_')) {
$delimiter .= $content[$j];
$j++;
}
if ($delimiter === '') {
return $i + 2; // Not a real heredoc
}
// Skip optional whitespace and the rest of the opening line
while ($j < $len && $content[$j] !== "\n") {
$j++;
}
if ($j < $len) {
$j++; // skip the newline after the opening line
}
// Scan line by line until the closing delimiter
while ($j < $len) {
$lineEnd = strpos($content, "\n", $j);
$lineEnd = ($lineEnd === false) ? $len : $lineEnd;
$line = substr($content, $j, $lineEnd - $j);
// For <<- (indent-stripping) form, the terminator may itself be indented;
// strip leading whitespace before comparing. For the non-stripping form
// (<<), the terminator must be at column 0 — but we still rtrim trailing
// whitespace/CR to handle Windows line-endings gracefully.
$normalised = $stripIndent ? trim($line) : rtrim($line);
if ($normalised === $delimiter) {
return $lineEnd + 1;
}
$j = $lineEnd + 1;
}
return $len; // unterminated heredoc — consume to EOF
}
/**
* Parse all file blocks inside a `files = [ … ]` array content,
* returning only those that have a `template` field.
*
* @param string $arrayContent Inner content between the outer `[` and `]`
* @param string $dirPath Directory prefix for the destination ('' = repo root)
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseFileBlocks(string $arrayContent, string $dirPath): array
{
$entries = [];
foreach ($this->splitBlocks($arrayContent) as $block) {
$entry = $this->parseFileBlock($block, $dirPath);
if ($entry !== null) {
$entries[] = $entry;
}
}
return $entries;
}
/**
* Parse a single file block `{ name = "…", template = "…", … }` or
* `{ name = "…", stub_content = <<-EOT … EOT, … }`.
*
* When a `stub_content` heredoc is present it takes priority over a
* `template` file-path reference. Returns null when the block has
* neither (structural-only entry that should not be synced).
*
* @return array{source?: string, inline_content?: string, destination: string, always_overwrite: bool}|null
*/
private function parseFileBlock(string $block, string $dirPath): ?array
{
// --- try stub_content heredoc first (preferred) ---
$inlineContent = $this->extractHeredoc($block, 'stub_content');
// --- fall back to stub_content as a quoted string (e.g. "line1\nline2") ---
if ($inlineContent === null) {
if (preg_match('/\bstub_content\s*=\s*"((?:[^"\\\\]|\\\\.)*)"/', $block, $m)) {
$inlineContent = stripcslashes($m[1]);
}
}
// --- fall back to external template path ---
$source = null;
if ($inlineContent === null) {
if (!preg_match('/\btemplate\s*=\s*"([^"]+)"/', $block, $m)) {
return null; // neither inline content nor template → structural entry
}
$source = $m[1];
}
// name is required
if (!preg_match('/\bname\s*=\s*"([^"]+)"/', $block, $m)) {
return null;
}
$filename = $m[1];
// destination_filename overrides name
if (preg_match('/\bdestination_filename\s*=\s*"([^"]+)"/', $block, $m)) {
$filename = $m[1];
}
// destination_path overrides dirPath
if (preg_match('/\bdestination_path\s*=\s*"([^"]+)"/', $block, $m)) {
$dp = trim($m[1], '/');
$destination = ($dp === '' || $dp === '.') ? $filename : "{$dp}/{$filename}";
} else {
$destination = $dirPath === '' ? $filename : "{$dirPath}/{$filename}";
}
// always_overwrite — default true for all template-driven files
$alwaysOverwrite = true;
if (preg_match('/\balways_overwrite\s*=\s*(true|false)\b/', $block, $m)) {
$alwaysOverwrite = ($m[1] === 'true');
}
// protected — when true, file is never overwritten even with --force
$protected = false;
if (preg_match('/\bprotected\s*=\s*(true|false)\b/', $block, $m)) {
$protected = ($m[1] === 'true');
}
if ($inlineContent !== null) {
return [
'inline_content' => $inlineContent,
'destination' => $destination,
'always_overwrite' => $alwaysOverwrite,
'protected' => $protected,
];
}
return [
'source' => $source,
'destination' => $destination,
'always_overwrite' => $alwaysOverwrite,
'protected' => $protected,
];
}
/**
* Extract a heredoc value for the given field name from a block string.
*
* Handles both `<<WORD` (content-preserving) and `<<-WORD`
* (indent-stripping) forms. Leading tabs/spaces are stripped uniformly
* when the `<<-` form is used, matching HCL semantics.
*
* Returns null when the field is not found.
*/
private function extractHeredoc(string $block, string $field): ?string
{
$pattern = '/\b' . preg_quote($field, '/') . '\s*=\s*<<(-?)(\w+)[ \t]*\r?\n(.*?)\r?\n[ \t]*\2[ \t]*(?:\r?\n|$)/s';
if (!preg_match($pattern, $block, $m)) {
return null;
}
$stripIndent = ($m[1] === '-');
$rawContent = $m[3];
if ($stripIndent) {
// Determine the minimum leading-whitespace prefix across non-empty lines
$lines = explode("\n", $rawContent);
$minIndent = PHP_INT_MAX;
foreach ($lines as $line) {
if (trim($line) === '') {
continue;
}
$indent = strlen($line) - strlen(ltrim($line, " \t"));
if ($indent < $minIndent) {
$minIndent = $indent;
}
}
if ($minIndent === PHP_INT_MAX) {
$minIndent = 0;
}
// Strip that many characters from the start of each line
$lines = array_map(
static fn(string $l) => (strlen($l) >= $minIndent) ? substr($l, $minIndent) : $l,
$lines
);
$rawContent = implode("\n", $lines);
}
return $rawContent;
}
/**
* Walk the `directories = [ … ]` array, descending into every
* `subdirectories` block recursively.
*
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseDirectories(string $dirsArrayContent): array
{
$entries = [];
foreach ($this->splitBlocks($dirsArrayContent) as $block) {
$entries = array_merge($entries, $this->parseDirectoryBlock($block));
}
return $entries;
}
/**
* Process one directory block: extract its path, parse its files, and
* recurse into any subdirectories.
*
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
private function parseDirectoryBlock(string $block): array
{
$entries = [];
// Determine the path prefix for files inside this directory
$dirPath = '';
if (preg_match('/\bpath\s*=\s*"([^"]+)"/', $block, $m)) {
$dirPath = $m[1];
}
// files = [ … ] inside this directory
$filesContent = $this->extractNamedArray($block, 'files');
if ($filesContent !== null) {
$entries = array_merge($entries, $this->parseFileBlocks($filesContent, $dirPath));
}
// subdirectories = [ … ] — recurse
$subdirsContent = $this->extractNamedArray($block, 'subdirectories');
if ($subdirsContent !== null) {
foreach ($this->splitBlocks($subdirsContent) as $subBlock) {
$entries = array_merge($entries, $this->parseDirectoryBlock($subBlock));
}
}
return $entries;
}
}