From fa1dfd06b956ed4121ce27bb770652b1147c21b7 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 9 Sep 2026 14:14:32 +0200 Subject: [PATCH] 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. --- scripts/translation/README.md | 28 +++ scripts/translation/check-structure.php | 281 ++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 scripts/translation/check-structure.php diff --git a/scripts/translation/README.md b/scripts/translation/README.md index 466bd9f847..b969544702 100644 --- a/scripts/translation/README.md +++ b/scripts/translation/README.md @@ -93,6 +93,27 @@ Files without revision tags in expected format will fail to generate pretty diffs on [Translation status](https://doc.php.net/revcheck.php) website or locally generated `revcheck.php` status pages. +## check-structure.php + +`doc-base/scripts/translation/check-structure.php` compares the block +structure of translated files with `doc-en`: the elements and their nesting, +not the prose they contain. An extra ``, a list turned into a paragraph, +or a `` that lost its `role` are reported. + +Unlike the scripts above, each file is compared with `doc-en` at the revision +its revision tag declares, so a file waiting for a sync produces no alert. +That makes this script usable as a blocking check on a pull request. + +File names are read from the command line, or from standard input when none +are given, which is how a CI job hands over the files a pull request touches. +Paths are relative to the translation directory. + +This script accepts a `--github` option, to report alerts as GitHub Actions +annotations instead of plain text. + +Files marked with ``, and files without a revision tag, +are skipped. Exit status is non zero when at least one difference is found. + ## Suggested execution 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 php doc-base/scripts/translation/qaxml-ws.php ``` +Structural comparison of the files changed by a pull request: + +``` +git diff --name-only "$BASE"...HEAD -- '*.xml' \ + | php doc-base/scripts/translation/check-structure.php --lang=$LANG +``` + Tags where is expected **no** translations: ``` diff --git a/scripts/translation/check-structure.php b/scripts/translation/check-structure.php new file mode 100644 index 0000000000..7dff8d9036 --- /dev/null +++ b/scripts/translation/check-structure.php @@ -0,0 +1,281 @@ +consume( position: 0 ); // script name +$help = $argv->consume( equals: "--help" ) ?? $argv->consume( equals: "-h" ); +$lang = $argv->consume( prefix: "--lang=" ); +$github = $argv->consume( equals: "--github" ); +$files = []; +foreach ( $argv->residual() as $arg ) + if ( strlen( $arg ) > 0 && $arg[0] != '-' ) + { + $files[] = $arg; + $argv->use( $arg ); + } +$argv->complete(); + +if ( $help !== null ) +{ + fwrite( STDERR , "Usage: check-structure.php [--lang=xx] [--github] [files...]\n\n" ); + fwrite( STDERR , "Reads file names from the command line, or from standard input when none\n" ); + fwrite( STDERR , "are given. Paths are relative to the translation directory.\n\n" ); + fwrite( STDERR , "See https://github.com/php/doc-base/tree/master/scripts/translation#readme for more info.\n" ); + exit( 0 ); +} + +$lang = requireLang( $lang ); +$files = $files === [] ? readPathsFromStdin() : $files; + +// -- Setup ----------------------------------------------------------------- + +/** + * Language directory, given by --lang= or by the last configure.php run. + */ +function requireLang( ?string $lang ) : string +{ + if ( $lang !== null && $lang !== '' ) + return $lang; + + $file = __DIR__ . '/../../temp/lang'; + + if ( ! file_exists( $file ) ) + { + fwrite( STDERR , "No language to process. Run 'doc-base/configure.php' or use '--lang='.\n" ); + exit( 1 ); + } + + return trim( file_get_contents( $file ) ); +} + +/** + * File names read from standard input, one per line, .xml only. This is how a + * CI job hands over the list of files a pull request touches. + */ +function readPathsFromStdin() : array +{ + $paths = []; + + foreach ( explode( "\n" , stream_get_contents( STDIN ) ) as $line ) + { + $path = trim( $line ); + if ( $path !== '' && str_ends_with( $path , '.xml' ) ) + $paths[] = $path; + } + + return $paths; +} + +// -- Skeleton construction ------------------------------------------------- + +/** + * Signature of an element: its name, followed by its structural attributes. + * gives "refsect1(role=description)". + */ +function elementSignature( DOMElement $element ) : string +{ + $attributes = []; + + foreach ( STRUCTURAL_ATTRIBUTES as $name ) + { + $value = $element->getAttribute( $name ); + if ( $value !== '' ) + $attributes[] = "$name=$value"; + } + + if ( $attributes === [] ) + return $element->nodeName; + + return $element->nodeName . '(' . implode( ',' , $attributes ) . ')'; +} + +/** + * Walks the tree depth first, appending the signature of each element, indented + * by its depth. Text containers are recorded but not entered. + */ +function collectSkeleton( DOMElement $element , string $depth , array & $skeleton ) : void +{ + // Translator credits exist only on the translation side, and would show up + // as a difference on every file that carries them. + + $isTranslatorCredits = $element->nodeName === 'authorgroup' + && str_starts_with( $element->getAttribute( 'xml:id' ) , 'translators' ); + + if ( $isTranslatorCredits ) + return; + + $skeleton[] = $depth . elementSignature( $element ); + + if ( in_array( $element->nodeName , TEXT_CONTAINERS , true ) ) + return; + + foreach ( $element->childNodes as $child ) + if ( $child->nodeType === XML_ELEMENT_NODE ) + collectSkeleton( $child , $depth . ' ' , $skeleton ); +} + +/** + * Skeleton of an XML fragment: the flat list of its block element signatures. + * Undeclared entities are left to XmlUtil::loadText(), which recovers from + * them; both sides go through the same loader, so the treatment is symmetric. + */ +function buildSkeleton( string $xml ) : array +{ + $document = XmlUtil::loadText( $xml ); + + if ( $document->documentElement === null ) + return [ '<>' ]; + + $skeleton = []; + collectSkeleton( $document->documentElement , '' , $skeleton ); + + return $skeleton; +} + +// -- doc-en access and comparison ------------------------------------------ + +/** + * Contents of a doc-en file at a given revision, or null when the file did not + * exist at that revision. + */ +function docEnFileAtRevision( string $hash , string $file ) : ?string +{ + $command = sprintf( + 'git -C %s show %s:%s 2>/dev/null' , + escapeshellarg( 'en' ) , + escapeshellarg( $hash ) , + escapeshellarg( $file ) + ); + + $contents = shell_exec( $command ); + + return ( $contents === null || $contents === '' ) ? null : $contents; +} + +/** + * First position where two skeletons differ, as [ enLine , targetLine ], or + * null when they are identical. A missing line is reported as "(none)". + */ +function firstDivergence( array $enSkeleton , array $targetSkeleton ) : ?array +{ + $length = max( count( $enSkeleton ) , count( $targetSkeleton ) ); + + for ( $i = 0 ; $i < $length ; $i++ ) + { + $enLine = $enSkeleton[ $i ] ?? ''; + $targetLine = $targetSkeleton[ $i ] ?? ''; + + if ( $enLine !== $targetLine ) + return [ + trim( $enSkeleton[ $i ] ?? '(none)' ) , + trim( $targetSkeleton[ $i ] ?? '(none)' ) , + ]; + } + + return null; +} + +// -- Main ------------------------------------------------------------------ +// +// Expected layout: 'en' and the translation directory side by side, as after a +// doc-base/configure.php run. + +$violations = []; +$checked = 0; + +foreach ( $files as $file ) +{ + $target = "$lang/$file"; + + if ( ! is_file( $target ) ) + continue; + + $targetXml = file_get_contents( $target ); + $revtag = RevtagParser::parseXmlText( $targetXml ); + + if ( $revtag->doNotTranslate ) + continue; + + // No revision tag: nothing tells us which doc-en version to compare with. + // qaxml-revtag.php is the script that reports those files. + + if ( $revtag->revision === '' ) + continue; + + $enXml = docEnFileAtRevision( $revtag->revision , $file ); + + // File absent on the doc-en side at that revision: newly added, renamed. + + if ( $enXml === null ) + continue; + + $checked++; + + $enSkeleton = buildSkeleton( $enXml ); + $targetSkeleton = buildSkeleton( $targetXml ); + $divergence = firstDivergence( $enSkeleton , $targetSkeleton ); + + if ( $divergence === null ) + continue; + + [ $enLine , $targetLine ] = $divergence; + + $violations[] = [ + $file , $enLine , $targetLine , + count( $enSkeleton ) , count( $targetSkeleton ) , + ]; +} + +foreach ( $violations as [ $file , $enLine , $targetLine , $enCount , $targetCount ] ) +{ + $message = sprintf( + 'structure differs from doc-en (EN: %s | translation: %s) [blocks EN=%d translation=%d]' , + $enLine , $targetLine , $enCount , $targetCount + ); + + // Under GitHub Actions the path must be relative to the repository being + // annotated, so that the alert lands on the right file of the pull request. + + if ( $github !== null ) + printf( "::error file=%s::%s\n" , $file , $message ); + else + printf( "%s/%s: %s\n" , $lang , $file , $message ); +} + +fprintf( STDERR , "checked=%d divergent=%d\n" , $checked , count( $violations ) ); + +exit( $violations === [] ? 0 : 1 );