|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * check-structure.php - Compares the block skeleton of a translated file with |
| 4 | + * the matching doc-en file to detect structural drift (an extra <note>, a list |
| 5 | + * turned into a paragraph, etc.). |
| 6 | + * |
| 7 | + * What is NOT compared: inline prose (the text of a <para>, a <title>...), which |
| 8 | + * legitimately differs from one language to another. We stop at the boundary of |
| 9 | + * text containers. |
| 10 | + * |
| 11 | + * Key point: each file is compared against doc-en *at the revision the file says |
| 12 | + * it mirrors* (the "EN-Revision: <hash>" comment), fetched via `git show`. As a |
| 13 | + * result, a file that lags behind sync does not produce a false positive, since |
| 14 | + * it is compared with the English it actually translated. |
| 15 | + * |
| 16 | + * Expected layout: the translation repository is at the root (current |
| 17 | + * directory), doc-en is in the `en/` subdirectory. |
| 18 | + * |
| 19 | + * Usage (from CI): |
| 20 | + * git diff --name-only ... | php .github/scripts/check-structure.php |
| 21 | + * STDIN one .xml path per line (relative to the repo root) |
| 22 | + * Output is GitHub Actions annotations (::error); the script exits non-zero if |
| 23 | + * at least one divergence is found. |
| 24 | + */ |
| 25 | + |
| 26 | +// --- DocBook vocabulary --------------------------------------------------- |
| 27 | + |
| 28 | +// Attributes that are part of the "structure": they are included in an |
| 29 | +// element's signature (a role= or an xml:id that changes = drift). |
| 30 | +const STRUCTURAL_ATTRIBUTES = ['role', 'choice', 'class', 'xml:id', 'rep']; |
| 31 | + |
| 32 | +// Elements whose content is text (inline). We record the element itself but do |
| 33 | +// NOT descend into it: its prose is the translator's business. |
| 34 | +const TEXT_CONTAINERS = [ |
| 35 | + 'para', 'simpara', 'term', 'title', 'titleabbrev', 'refpurpose', 'refname', |
| 36 | + 'member', 'entry', 'literallayout', 'programlisting', 'screen', 'seg', |
| 37 | + 'segtitle', 'synopsis', |
| 38 | +]; |
| 39 | + |
| 40 | +// --- Skeleton construction ------------------------------------------------ |
| 41 | + |
| 42 | +/** |
| 43 | + * Replaces every named entity (&reftitle.intro;, &warn.foo;, ...) with a plain |
| 44 | + * "E" text. Two reasons: |
| 45 | + * 1. without this, the DOM refuses to parse (undeclared entities outside a DTD); |
| 46 | + * 2. it is symmetric - EN and the translation have the same entities at the |
| 47 | + * same places, so replacing them the same way on both sides creates no |
| 48 | + * artificial difference. |
| 49 | + * Predefined XML entities (& < ...) and numeric ones are left intact. |
| 50 | + */ |
| 51 | +function entitiesToPlaceholder(string $xml): string |
| 52 | +{ |
| 53 | + $namedEntity = '/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)[\w.:-]+;/'; |
| 54 | + return preg_replace($namedEntity, 'E', $xml); |
| 55 | +} |
| 56 | + |
| 57 | +/** |
| 58 | + * An element's signature: its name followed by its structural attributes. |
| 59 | + * E.g. <refsect1 role="description"> gives "refsect1(role=description)". |
| 60 | + */ |
| 61 | +function elementSignature(DOMElement $element): string |
| 62 | +{ |
| 63 | + $attributes = []; |
| 64 | + foreach (STRUCTURAL_ATTRIBUTES as $name) { |
| 65 | + $value = $element->getAttribute($name); |
| 66 | + if ($value !== '') { |
| 67 | + $attributes[] = "$name=$value"; |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + if ($attributes === []) { |
| 72 | + return $element->nodeName; |
| 73 | + } |
| 74 | + return $element->nodeName . '(' . implode(',', $attributes) . ')'; |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Walks the tree depth-first and appends each element's signature (prefixed by |
| 79 | + * its depth, in spaces) into $skeleton. We stop at the boundary of text |
| 80 | + * containers: the container is recorded but not its content. |
| 81 | + */ |
| 82 | +function collectSkeleton(DOMElement $element, string $depth, array &$skeleton): void |
| 83 | +{ |
| 84 | + // Translator credits are specific to the translation (absent from doc-en): |
| 85 | + // we ignore them so as not to report a false drift. |
| 86 | + $isTranslatorCredits = $element->nodeName === 'authorgroup' |
| 87 | + && str_starts_with($element->getAttribute('xml:id'), 'translators'); |
| 88 | + if ($isTranslatorCredits) { |
| 89 | + return; |
| 90 | + } |
| 91 | + |
| 92 | + $skeleton[] = $depth . elementSignature($element); |
| 93 | + |
| 94 | + // Text container: we do not enter it (its prose may diverge). |
| 95 | + if (in_array($element->nodeName, TEXT_CONTAINERS, true)) { |
| 96 | + return; |
| 97 | + } |
| 98 | + |
| 99 | + foreach ($element->childNodes as $child) { |
| 100 | + if ($child->nodeType === XML_ELEMENT_NODE) { |
| 101 | + collectSkeleton($child, $depth . ' ', $skeleton); |
| 102 | + } |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +/** |
| 107 | + * Skeleton of an XML document given as a string: the flat list of its block |
| 108 | + * element signatures. Returns ['<<INVALID>>'] if the XML is unreadable. |
| 109 | + */ |
| 110 | +function buildSkeleton(string $xml): array |
| 111 | +{ |
| 112 | + $document = new DOMDocument(); |
| 113 | + libxml_use_internal_errors(true); // we handle parse errors ourselves |
| 114 | + $parsed = $document->loadXML(entitiesToPlaceholder($xml), LIBXML_NONET); |
| 115 | + libxml_clear_errors(); |
| 116 | + |
| 117 | + if (!$parsed || !$document->documentElement) { |
| 118 | + return ['<<INVALID>>']; |
| 119 | + } |
| 120 | + |
| 121 | + $skeleton = []; |
| 122 | + collectSkeleton($document->documentElement, '', $skeleton); |
| 123 | + return $skeleton; |
| 124 | +} |
| 125 | + |
| 126 | +// --- doc-en access and comparison ----------------------------------------- |
| 127 | + |
| 128 | +/** |
| 129 | + * Content of a doc-en file at a given revision (`git show <hash>:<path>`), or |
| 130 | + * null if the file did not exist at that revision. |
| 131 | + */ |
| 132 | +function docEnFileAtRevision(string $enRepo, string $hash, string $relativePath): ?string |
| 133 | +{ |
| 134 | + $command = sprintf( |
| 135 | + 'git -C %s show %s:%s 2>/dev/null', |
| 136 | + escapeshellarg($enRepo), |
| 137 | + escapeshellarg($hash), |
| 138 | + escapeshellarg($relativePath) |
| 139 | + ); |
| 140 | + $content = shell_exec($command); |
| 141 | + return ($content === null || $content === '') ? null : $content; |
| 142 | +} |
| 143 | + |
| 144 | +/** |
| 145 | + * First position where two skeletons differ, as [enLine, translationLine], or |
| 146 | + * null if they are identical. A missing line (shorter skeleton) is represented |
| 147 | + * by "(none)". |
| 148 | + */ |
| 149 | +function firstDivergence(array $enSkeleton, array $translationSkeleton): ?array |
| 150 | +{ |
| 151 | + $length = max(count($enSkeleton), count($translationSkeleton)); |
| 152 | + for ($i = 0; $i < $length; $i++) { |
| 153 | + $enLine = $enSkeleton[$i] ?? ''; |
| 154 | + $translationLine = $translationSkeleton[$i] ?? ''; |
| 155 | + if ($enLine !== $translationLine) { |
| 156 | + return [ |
| 157 | + trim($enSkeleton[$i] ?? '(none)'), |
| 158 | + trim($translationSkeleton[$i] ?? '(none)'), |
| 159 | + ]; |
| 160 | + } |
| 161 | + } |
| 162 | + return null; |
| 163 | +} |
| 164 | + |
| 165 | +// --- Input / output ------------------------------------------------------- |
| 166 | + |
| 167 | +/** Reads the (.xml) paths given on standard input, one per line. */ |
| 168 | +function readPathsFromStdin(): array |
| 169 | +{ |
| 170 | + $paths = []; |
| 171 | + foreach (explode("\n", stream_get_contents(STDIN)) as $line) { |
| 172 | + $path = trim($line); |
| 173 | + if ($path !== '' && str_ends_with($path, '.xml')) { |
| 174 | + $paths[] = $path; |
| 175 | + } |
| 176 | + } |
| 177 | + return $paths; |
| 178 | +} |
| 179 | + |
| 180 | +/** |
| 181 | + * Emits each divergence as a GitHub Actions annotation (::error). The path is |
| 182 | + * relative to the translation repo root, so the annotation lands on the right |
| 183 | + * file in the PR. The message is in Spanish (contributor-facing). |
| 184 | + */ |
| 185 | +function printAnnotations(array $violations): void |
| 186 | +{ |
| 187 | + foreach ($violations as [$file, $enLine, $translationLine, $enCount, $translationCount]) { |
| 188 | + $message = sprintf( |
| 189 | + 'structure differs from doc-en (EN: %s | translation: %s) [blocks EN=%d translation=%d]', |
| 190 | + $enLine, $translationLine, $enCount, $translationCount |
| 191 | + ); |
| 192 | + printf("::error file=%s::%s\n", $file, $message); |
| 193 | + } |
| 194 | +} |
| 195 | + |
| 196 | +// --- Main program --------------------------------------------------------- |
| 197 | +// |
| 198 | +// Expected layout: the translation repository is at the root (current |
| 199 | +// directory), doc-en is in the `en/` subdirectory. |
| 200 | + |
| 201 | +$workspace = getcwd(); |
| 202 | +$enRepo = "$workspace/en"; |
| 203 | + |
| 204 | +$violations = []; // each entry: [path, enLine, translationLine, enBlocks, translationBlocks] |
| 205 | +$checkedCount = 0; |
| 206 | + |
| 207 | +foreach (readPathsFromStdin() as $relativePath) { |
| 208 | + $translationPath = "$workspace/$relativePath"; |
| 209 | + if (!is_file($translationPath)) { |
| 210 | + continue; |
| 211 | + } |
| 212 | + $translationXml = file_get_contents($translationPath); |
| 213 | + |
| 214 | + // Which EN revision does this translation claim to mirror? |
| 215 | + if (!preg_match('/EN-Revision:\s*([0-9a-f]{40})/', $translationXml, $match)) { |
| 216 | + continue; // no hash: we do not know what to compare against |
| 217 | + } |
| 218 | + $enRevision = $match[1]; |
| 219 | + |
| 220 | + $enXml = docEnFileAtRevision($enRepo, $enRevision, $relativePath); |
| 221 | + if ($enXml === null) { |
| 222 | + continue; // file absent on the EN side at that hash (new, renamed...) |
| 223 | + } |
| 224 | + |
| 225 | + $checkedCount++; |
| 226 | + $enSkeleton = buildSkeleton($enXml); |
| 227 | + $translationSkeleton = buildSkeleton($translationXml); |
| 228 | + |
| 229 | + $divergence = firstDivergence($enSkeleton, $translationSkeleton); |
| 230 | + if ($divergence === null) { |
| 231 | + continue; // identical structures: nothing to report |
| 232 | + } |
| 233 | + |
| 234 | + [$enLine, $translationLine] = $divergence; |
| 235 | + $violations[] = [ |
| 236 | + $relativePath, $enLine, $translationLine, |
| 237 | + count($enSkeleton), count($translationSkeleton), |
| 238 | + ]; |
| 239 | +} |
| 240 | + |
| 241 | +printAnnotations($violations); |
| 242 | +fprintf(STDERR, "checked=%d divergent=%d\n", $checkedCount, count($violations)); |
| 243 | + |
| 244 | +exit($violations ? 1 : 0); |
0 commit comments