Skip to content

Commit f7ce374

Browse files
committed
CI: check .xml structure drift against doc-en
1 parent 08e5b7a commit f7ce374

2 files changed

Lines changed: 283 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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 (&amp; &lt; ...) 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);

.github/workflows/check-xml.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# https://docs.github.com/en/actions
2+
# Checks that the block skeleton of the .xml files changed in a PR stays a mirror
3+
# of doc-en. Each file is compared to doc-en at the revision it declares to mirror
4+
# (EN-Revision), so a lagging file does not raise a false positive. The script
5+
# emits its own ::error annotations.
6+
7+
name: "Structure"
8+
9+
on:
10+
pull_request:
11+
branches: ["master"]
12+
types: [opened, synchronize]
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
structure:
19+
name: "Check XML"
20+
runs-on: ubuntu-22.04
21+
steps:
22+
- name: "Checkout translation"
23+
uses: actions/checkout@v4
24+
with:
25+
fetch-depth: 0
26+
27+
- name: "Checkout php/doc-en"
28+
uses: actions/checkout@v4
29+
with:
30+
path: en
31+
repository: php/doc-en
32+
fetch-depth: 0
33+
34+
- name: "Check structure"
35+
run: |
36+
BASE="${{ github.event.pull_request.base.sha }}"
37+
git fetch --no-tags --depth=1 origin "$BASE"
38+
git diff --name-only "$BASE"...HEAD -- '*.xml' \
39+
| php8.1 .github/scripts/check-structure.php

0 commit comments

Comments
 (0)