Skip to content

Commit fa1dfd0

Browse files
committed
Add check-structure.php, comparing translation structure at declared revision
The qaxml-* scripts compare a translation with the current doc-en file, so a file waiting for a sync reports differences that are only lag. This one reads the revision tag of each file and compares with doc-en at that revision, so those files produce no alert and the check can block a pull request. Six translations carry a copy of this script in their own .github/scripts directory, in three variants; one of them never received the fix that added href and xpointer to the compared attributes, and lets a translated XInclude target through.
1 parent 879d2f1 commit fa1dfd0

2 files changed

Lines changed: 309 additions & 0 deletions

File tree

‎scripts/translation/README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,27 @@ Files without revision tags in expected format will fail to generate pretty
9393
diffs on [Translation status](https://doc.php.net/revcheck.php) website or
9494
locally generated `revcheck.php` status pages.
9595

96+
## check-structure.php
97+
98+
`doc-base/scripts/translation/check-structure.php` compares the block
99+
structure of translated files with `doc-en`: the elements and their nesting,
100+
not the prose they contain. An extra `<note>`, a list turned into a paragraph,
101+
or a `<refsect1>` that lost its `role` are reported.
102+
103+
Unlike the scripts above, each file is compared with `doc-en` at the revision
104+
its revision tag declares, so a file waiting for a sync produces no alert.
105+
That makes this script usable as a blocking check on a pull request.
106+
107+
File names are read from the command line, or from standard input when none
108+
are given, which is how a CI job hands over the files a pull request touches.
109+
Paths are relative to the translation directory.
110+
111+
This script accepts a `--github` option, to report alerts as GitHub Actions
112+
annotations instead of plain text.
113+
114+
Files marked with `<?do-not-translate?>`, and files without a revision tag,
115+
are skipped. Exit status is non zero when at least one difference is found.
116+
96117
## Suggested execution
97118

98119
The first execution of these scripts may generate an inordinate amount of
@@ -114,6 +135,13 @@ php doc-base/scripts/translation/qaxml-tags.php --detail
114135
php doc-base/scripts/translation/qaxml-ws.php
115136
```
116137

138+
Structural comparison of the files changed by a pull request:
139+
140+
```
141+
git diff --name-only "$BASE"...HEAD -- '*.xml' \
142+
| php doc-base/scripts/translation/check-structure.php --lang=$LANG
143+
```
144+
117145
Tags where is expected **no** translations:
118146

119147
```
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
<?php /*
2+
+----------------------------------------------------------------------+
3+
| Copyright (c) 1997-2026 The PHP Group |
4+
+----------------------------------------------------------------------+
5+
| This source file is subject to version 3.01 of the PHP license, |
6+
| that is bundled with this package in the file LICENSE, and is |
7+
| available through the world-wide-web at the following url: |
8+
| https://www.php.net/license/3_01.txt. |
9+
| If you did not receive a copy of the PHP license and are unable to |
10+
| obtain it through the world-wide-web, please send a note to |
11+
| license@php.net, so we can mail you a copy immediately. |
12+
+----------------------------------------------------------------------+
13+
14+
# Description
15+
16+
Compare the block structure of translated files with doc-en, at the revision
17+
each file declares to mirror. */
18+
19+
require_once __DIR__ . '/libqa/all.php';
20+
require_once __DIR__ . '/lib/XmlUtil.php';
21+
require_once __DIR__ . '/lib/RevtagParser.php';
22+
23+
// Attributes that take part in a signature. href and xpointer come from
24+
// XInclude: a translated target selects nothing and breaks the build, so they
25+
// must mirror doc-en exactly.
26+
27+
const STRUCTURAL_ATTRIBUTES = [ 'role' , 'choice' , 'class' , 'xml:id' , 'rep' , 'href' , 'xpointer' ];
28+
29+
// Elements whose content is text. The element itself is recorded, its content
30+
// is not walked: the prose inside is the translator's business.
31+
32+
const TEXT_CONTAINERS = [
33+
'para' , 'simpara' , 'term' , 'title' , 'titleabbrev' , 'refpurpose' ,
34+
'refname' , 'member' , 'entry' , 'literallayout' , 'programlisting' ,
35+
'screen' , 'seg' , 'segtitle' , 'synopsis' ,
36+
];
37+
38+
$argv = new ArgvParser( $argv );
39+
$argv->consume( position: 0 ); // script name
40+
$help = $argv->consume( equals: "--help" ) ?? $argv->consume( equals: "-h" );
41+
$lang = $argv->consume( prefix: "--lang=" );
42+
$github = $argv->consume( equals: "--github" );
43+
$files = [];
44+
foreach ( $argv->residual() as $arg )
45+
if ( strlen( $arg ) > 0 && $arg[0] != '-' )
46+
{
47+
$files[] = $arg;
48+
$argv->use( $arg );
49+
}
50+
$argv->complete();
51+
52+
if ( $help !== null )
53+
{
54+
fwrite( STDERR , "Usage: check-structure.php [--lang=xx] [--github] [files...]\n\n" );
55+
fwrite( STDERR , "Reads file names from the command line, or from standard input when none\n" );
56+
fwrite( STDERR , "are given. Paths are relative to the translation directory.\n\n" );
57+
fwrite( STDERR , "See https://github.com/php/doc-base/tree/master/scripts/translation#readme for more info.\n" );
58+
exit( 0 );
59+
}
60+
61+
$lang = requireLang( $lang );
62+
$files = $files === [] ? readPathsFromStdin() : $files;
63+
64+
// -- Setup -----------------------------------------------------------------
65+
66+
/**
67+
* Language directory, given by --lang= or by the last configure.php run.
68+
*/
69+
function requireLang( ?string $lang ) : string
70+
{
71+
if ( $lang !== null && $lang !== '' )
72+
return $lang;
73+
74+
$file = __DIR__ . '/../../temp/lang';
75+
76+
if ( ! file_exists( $file ) )
77+
{
78+
fwrite( STDERR , "No language to process. Run 'doc-base/configure.php' or use '--lang='.\n" );
79+
exit( 1 );
80+
}
81+
82+
return trim( file_get_contents( $file ) );
83+
}
84+
85+
/**
86+
* File names read from standard input, one per line, .xml only. This is how a
87+
* CI job hands over the list of files a pull request touches.
88+
*/
89+
function readPathsFromStdin() : array
90+
{
91+
$paths = [];
92+
93+
foreach ( explode( "\n" , stream_get_contents( STDIN ) ) as $line )
94+
{
95+
$path = trim( $line );
96+
if ( $path !== '' && str_ends_with( $path , '.xml' ) )
97+
$paths[] = $path;
98+
}
99+
100+
return $paths;
101+
}
102+
103+
// -- Skeleton construction -------------------------------------------------
104+
105+
/**
106+
* Signature of an element: its name, followed by its structural attributes.
107+
* <refsect1 role="description"> gives "refsect1(role=description)".
108+
*/
109+
function elementSignature( DOMElement $element ) : string
110+
{
111+
$attributes = [];
112+
113+
foreach ( STRUCTURAL_ATTRIBUTES as $name )
114+
{
115+
$value = $element->getAttribute( $name );
116+
if ( $value !== '' )
117+
$attributes[] = "$name=$value";
118+
}
119+
120+
if ( $attributes === [] )
121+
return $element->nodeName;
122+
123+
return $element->nodeName . '(' . implode( ',' , $attributes ) . ')';
124+
}
125+
126+
/**
127+
* Walks the tree depth first, appending the signature of each element, indented
128+
* by its depth. Text containers are recorded but not entered.
129+
*/
130+
function collectSkeleton( DOMElement $element , string $depth , array & $skeleton ) : void
131+
{
132+
// Translator credits exist only on the translation side, and would show up
133+
// as a difference on every file that carries them.
134+
135+
$isTranslatorCredits = $element->nodeName === 'authorgroup'
136+
&& str_starts_with( $element->getAttribute( 'xml:id' ) , 'translators' );
137+
138+
if ( $isTranslatorCredits )
139+
return;
140+
141+
$skeleton[] = $depth . elementSignature( $element );
142+
143+
if ( in_array( $element->nodeName , TEXT_CONTAINERS , true ) )
144+
return;
145+
146+
foreach ( $element->childNodes as $child )
147+
if ( $child->nodeType === XML_ELEMENT_NODE )
148+
collectSkeleton( $child , $depth . ' ' , $skeleton );
149+
}
150+
151+
/**
152+
* Skeleton of an XML fragment: the flat list of its block element signatures.
153+
* Undeclared entities are left to XmlUtil::loadText(), which recovers from
154+
* them; both sides go through the same loader, so the treatment is symmetric.
155+
*/
156+
function buildSkeleton( string $xml ) : array
157+
{
158+
$document = XmlUtil::loadText( $xml );
159+
160+
if ( $document->documentElement === null )
161+
return [ '<<INVALID>>' ];
162+
163+
$skeleton = [];
164+
collectSkeleton( $document->documentElement , '' , $skeleton );
165+
166+
return $skeleton;
167+
}
168+
169+
// -- doc-en access and comparison ------------------------------------------
170+
171+
/**
172+
* Contents of a doc-en file at a given revision, or null when the file did not
173+
* exist at that revision.
174+
*/
175+
function docEnFileAtRevision( string $hash , string $file ) : ?string
176+
{
177+
$command = sprintf(
178+
'git -C %s show %s:%s 2>/dev/null' ,
179+
escapeshellarg( 'en' ) ,
180+
escapeshellarg( $hash ) ,
181+
escapeshellarg( $file )
182+
);
183+
184+
$contents = shell_exec( $command );
185+
186+
return ( $contents === null || $contents === '' ) ? null : $contents;
187+
}
188+
189+
/**
190+
* First position where two skeletons differ, as [ enLine , targetLine ], or
191+
* null when they are identical. A missing line is reported as "(none)".
192+
*/
193+
function firstDivergence( array $enSkeleton , array $targetSkeleton ) : ?array
194+
{
195+
$length = max( count( $enSkeleton ) , count( $targetSkeleton ) );
196+
197+
for ( $i = 0 ; $i < $length ; $i++ )
198+
{
199+
$enLine = $enSkeleton[ $i ] ?? '';
200+
$targetLine = $targetSkeleton[ $i ] ?? '';
201+
202+
if ( $enLine !== $targetLine )
203+
return [
204+
trim( $enSkeleton[ $i ] ?? '(none)' ) ,
205+
trim( $targetSkeleton[ $i ] ?? '(none)' ) ,
206+
];
207+
}
208+
209+
return null;
210+
}
211+
212+
// -- Main ------------------------------------------------------------------
213+
//
214+
// Expected layout: 'en' and the translation directory side by side, as after a
215+
// doc-base/configure.php run.
216+
217+
$violations = [];
218+
$checked = 0;
219+
220+
foreach ( $files as $file )
221+
{
222+
$target = "$lang/$file";
223+
224+
if ( ! is_file( $target ) )
225+
continue;
226+
227+
$targetXml = file_get_contents( $target );
228+
$revtag = RevtagParser::parseXmlText( $targetXml );
229+
230+
if ( $revtag->doNotTranslate )
231+
continue;
232+
233+
// No revision tag: nothing tells us which doc-en version to compare with.
234+
// qaxml-revtag.php is the script that reports those files.
235+
236+
if ( $revtag->revision === '' )
237+
continue;
238+
239+
$enXml = docEnFileAtRevision( $revtag->revision , $file );
240+
241+
// File absent on the doc-en side at that revision: newly added, renamed.
242+
243+
if ( $enXml === null )
244+
continue;
245+
246+
$checked++;
247+
248+
$enSkeleton = buildSkeleton( $enXml );
249+
$targetSkeleton = buildSkeleton( $targetXml );
250+
$divergence = firstDivergence( $enSkeleton , $targetSkeleton );
251+
252+
if ( $divergence === null )
253+
continue;
254+
255+
[ $enLine , $targetLine ] = $divergence;
256+
257+
$violations[] = [
258+
$file , $enLine , $targetLine ,
259+
count( $enSkeleton ) , count( $targetSkeleton ) ,
260+
];
261+
}
262+
263+
foreach ( $violations as [ $file , $enLine , $targetLine , $enCount , $targetCount ] )
264+
{
265+
$message = sprintf(
266+
'structure differs from doc-en (EN: %s | translation: %s) [blocks EN=%d translation=%d]' ,
267+
$enLine , $targetLine , $enCount , $targetCount
268+
);
269+
270+
// Under GitHub Actions the path must be relative to the repository being
271+
// annotated, so that the alert lands on the right file of the pull request.
272+
273+
if ( $github !== null )
274+
printf( "::error file=%s::%s\n" , $file , $message );
275+
else
276+
printf( "%s/%s: %s\n" , $lang , $file , $message );
277+
}
278+
279+
fprintf( STDERR , "checked=%d divergent=%d\n" , $checked , count( $violations ) );
280+
281+
exit( $violations === [] ? 0 : 1 );

0 commit comments

Comments
 (0)