diff --git a/decisions/answers/ans-134-static-label-references-warn-and-never-hide-a-resource.md b/decisions/answers/ans-134-static-label-references-warn-and-never-hide-a-resource.md new file mode 100644 index 000000000..fbe8ff31b --- /dev/null +++ b/decisions/answers/ans-134-static-label-references-warn-and-never-hide-a-resource.md @@ -0,0 +1,56 @@ +--- +id: D-ANS-134 +title: Static label references warn and never hide a resource +date: 2026-09-01 +status: open +coveredBy: + - LabelSearchTest::aStaticReferenceIsNamed + - LabelSearchTest::anUnreferencedResourceStaysVisible +--- + +# D-ANS-134 — Static label references warn and never hide a resource + +**A label resource with no static reference stays in `typo3_label_lookup` and is +marked with a warning.** + +## Evidence + +- TYPO3 main's `LabelFileResolver::getAllLabelFilesOfPackage()` recursively + enumerates XLF files below `Resources/Private/Language/` and + `Configuration/Sets/`; it does not first establish a reference. +- `YamlSetDefinitionProvider::get()` selects `labels.xlf` beside a set's + `config.yaml` when the manifest declares no `labels` value. That reference is + implicit and no text search can find it. +- A translation domain is a string an application can assemble at runtime. A + static scan can establish a reference it found and cannot establish that no + runtime reference exists. + +Both core classes were read on TYPO3 main on 2026-09-01. The official Site +folder reference lists the supported files below `config/sites//` +and names no XLF resource there, so a project-site XLF is reported as requiring +an explicit reference. + +## Decided + +- Search PHP, YAML, TypoScript, TSconfig, Fluid, JavaScript, TypeScript, JSON + and XML files in the project and its installed TYPO3 packages for the exact + file reference or its translation domain. +- Count the conventional site-set `labels.xlf` beside `config.yaml` as an + implicit reference. +- Report the paths that carry a reference and warn where the list is empty. +- Keep an unreferenced resource in the results. Excluding it would turn an + incomplete static reading into a runtime claim. + +## Assumed + +- A reference in executable or configuration source is useful evidence even + where the branch that carries it is not active in this installation. +- Documentation prose is not a reference and is outside the scanned file types. + +## Wrong if + +- TYPO3 exposes a complete runtime usage graph for translation resources. Then + that graph should replace the static warning. +- A reference can be established only by parsing each supported source language. + Then plain containment produces enough false positives to mislead callers and + the scan needs language-specific readers. diff --git a/decisions/answers/readme.md b/decisions/answers/readme.md index a8e4de083..f8a560047 100644 --- a/decisions/answers/readme.md +++ b/decisions/answers/readme.md @@ -14,6 +14,7 @@ one is added. - [`D-ANS-129`][D-ANS-129] — A list in an answer names the call it raises · 2026-09-01 - [`D-ANS-130`][D-ANS-130] — A hint answer says how much of the question it carries · 2026-09-01 - [`D-ANS-131`][D-ANS-131] — An icon answer says whose picture the identifier already is · 2026-09-01 +- [`D-ANS-134`][D-ANS-134] — Static label references warn and never hide a resource · 2026-09-01 - [`D-ANS-126`][D-ANS-126] — The runnable form of a declared command is a field · 2026-08-28 - [`D-ANS-114`][D-ANS-114] — A page read whole names the hints it declares · 2026-08-27 - [`D-ANS-115`][D-ANS-115] — A phrasing a requirement rests on is carried by the hint's own vocabulary · 2026-08-27 @@ -143,6 +144,7 @@ one is added. [D-ANS-129]: ans-129-a-list-in-an-answer-names-the-call-it-raises.md [D-ANS-130]: ans-130-a-hint-answer-says-how-much-of-the-question-it-carries.md [D-ANS-131]: ans-131-an-icon-answer-says-whose-picture-the-identifier-already-is.md +[D-ANS-134]: ans-134-static-label-references-warn-and-never-hide-a-resource.md [D-ANS-126]: ans-126-the-runnable-form-of-a-declared-command-is-a-field.md [D-ANS-114]: ans-114-a-page-read-whole-names-the-hints-it-declares.md [D-ANS-115]: ans-115-a-phrasing-a-requirement-rests-on-is-carried-by-the-hints-own-vocabulary.md diff --git a/decisions/readme.md b/decisions/readme.md index a48c9126e..9b7c27993 100644 --- a/decisions/readme.md +++ b/decisions/readme.md @@ -97,6 +97,7 @@ and not a defect. What was decided lately is `bin/cli decisions:list`. - [`D-ANS-129`][D-ANS-129] — A list in an answer names the call it raises · 2026-09-01 - [`D-ANS-130`][D-ANS-130] — A hint answer says how much of the question it carries · 2026-09-01 - [`D-ANS-131`][D-ANS-131] — An icon answer says whose picture the identifier already is · 2026-09-01 +- [`D-ANS-134`][D-ANS-134] — Static label references warn and never hide a resource · 2026-09-01 - [`D-ANS-126`][D-ANS-126] — The runnable form of a declared command is a field · 2026-08-28 - [`D-ANS-114`][D-ANS-114] — A page read whole names the hints it declares · 2026-08-27 - [`D-ANS-115`][D-ANS-115] — A phrasing a requirement rests on is carried by the hint's own vocabulary · 2026-08-27 @@ -226,6 +227,7 @@ and not a defect. What was decided lately is `bin/cli decisions:list`. [D-ANS-129]: answers/ans-129-a-list-in-an-answer-names-the-call-it-raises.md [D-ANS-130]: answers/ans-130-a-hint-answer-says-how-much-of-the-question-it-carries.md [D-ANS-131]: answers/ans-131-an-icon-answer-says-whose-picture-the-identifier-already-is.md +[D-ANS-134]: answers/ans-134-static-label-references-warn-and-never-hide-a-resource.md [D-ANS-126]: answers/ans-126-the-runnable-form-of-a-declared-command-is-a-field.md [D-ANS-114]: answers/ans-114-a-page-read-whole-names-the-hints-it-declares.md [D-ANS-115]: answers/ans-115-a-phrasing-a-requirement-rests-on-is-carried-by-the-hints-own-vocabulary.md diff --git a/documentation/server/tools/readme.rst b/documentation/server/tools/readme.rst index 3416b91cc..ffdcbbbbb 100644 --- a/documentation/server/tools/readme.rst +++ b/documentation/server/tools/readme.rst @@ -164,7 +164,7 @@ at all in the state the machine is in. :action: Open reference Search the labels registered in the TYPO3 installation you are working - in. + in and the XLF files below project config/sites. .. card:: :doc:`typo3_permalink_lookup ` :label: Lookup diff --git a/documentation/server/tools/typo3_label_lookup.rst b/documentation/server/tools/typo3_label_lookup.rst index d0dcedfcf..e9e940a2b 100644 --- a/documentation/server/tools/typo3_label_lookup.rst +++ b/documentation/server/tools/typo3_label_lookup.rst @@ -3,16 +3,16 @@ ``typo3_label_lookup`` ====================== -Search the labels registered in the TYPO3 installation you are working in. Reuse -is local to the translation resource already used at the consuming code: pass -resource whenever it is known, and do not reference a match from another module -or package merely because its text is identical. The console answers with the -resource overrides the installation applies; where it cannot be reached — an -installed TYPO3 whose database has no schema yet is the common case — the same -packages' XLF files are read instead. Every match comes back as a translation -domain reference; computing that reference for a file this installation does not -have, one a patch is about to add, is typo3_translation_domain_lookup. Answers -from: installation, packages. +Search the labels registered in the TYPO3 installation you are working in and +the XLF files below project config/sites. Reuse is local to the translation +resource already used at the consuming code: pass resource whenever it is known, +and do not reference a match from another module or package merely because its +text is identical. The console answers with the resource overrides the +installation applies; the files supply an answer when it cannot be reached and +report non-standard names or resources with no static reference. Every match +comes back as a translation domain reference; computing that reference for a +file this installation does not have, one a patch is about to add, is +typo3_translation_domain_lookup. Answers from: installation, packages. ``readOnlyHint: true`` · ``destructiveHint: false`` · ``idempotentHint: true`` · ``openWorldHint: false`` @@ -75,10 +75,26 @@ Answers with # a path that was guessed can be replaced by one that exists. Empty means no # resource holds such a label. resources: [string] # optional + resourceDiagnostics: # optional + - # The XLF resource this diagnosis describes. + resource: string + # Where it was found: package, site-set, or project-site. + location: string + # Whether the file follows the naming convention for its location. + conventionalName: boolean + # Whether an implicit or static reference was found. + referenced: boolean + # Source files that name the resource. A conventional site-set labels.xlf + # names its adjacent config.yaml as an implicit reference. + references: [string] + # Naming, discovery, and static-reference warnings for this resource. + warnings: [string] labels: # optional - - # Translation domain reference (package.resource:key) — the canonical - # form. + - # The reusable label reference: a translation domain for package labels or + # an LLL file reference for project-site labels. ref: string + # The translation domain, empty for a project-site XLF that TYPO3 does not + # register as a package resource. domain: string # The trans-unit id. key: string diff --git a/requirements/answers/ans-038-a-file-read-label-names-what-makes-its-resource-usable.md b/requirements/answers/ans-038-a-file-read-label-names-what-makes-its-resource-usable.md new file mode 100644 index 000000000..eb5b7bbf9 --- /dev/null +++ b/requirements/answers/ans-038-a-file-read-label-names-what-makes-its-resource-usable.md @@ -0,0 +1,33 @@ +--- +id: R-ANS-038 +title: A file-read label names what makes its resource usable +status: held +heldBy: + - LabelSearchTest::aNonStandardLabelFileIsWarned + - LabelSearchTest::aProjectSiteLabelFileIsReadBesideAnEmptyConsoleAnswer + - LabelSearchTest::aSiteSetLabelsFileCarriesItsImplicitReference + - LabelSearchTest::aStaticReferenceIsNamed + - LabelSearchTest::anUnreferencedResourceStaysVisible +--- + +# R-ANS-038 — A file-read label names what makes its resource usable + +**A label read directly from a file says whether its name follows the convention +of its directory and where a static reference reaches it.** + +`typo3_label_lookup` also reads XLF files below the project's `config/sites/` +directory. TYPO3 does not discover those as package language resources, so the +answer says that an explicit reference is required. A resource for which the +scan finds no reference stays in the answer and is warned: a domain assembled at +runtime cannot be proved absent from source files. + +The automatic site-set case is a reference of its own. A `labels.xlf` beside a +set's `config.yaml` is selected by TYPO3 without the path being written into +that file, and the answer names that implicit reference rather than warning that +none exists. + +## From + +The maintainer's request of 2026-09-01, after the package fallback was found to +enumerate every matching trans-unit without saying whether its resource could be +reached. diff --git a/requirements/answers/readme.md b/requirements/answers/readme.md index 8abe85b63..d19fdee9b 100644 --- a/requirements/answers/readme.md +++ b/requirements/answers/readme.md @@ -45,6 +45,7 @@ when it is added. - [`R-ANS-035`][R-ANS-035] — An answer that names a target branch names the lines that take a patch · held - [`R-ANS-036`][R-ANS-036] — A suite list names the paths no suite covers · held - [`R-ANS-037`][R-ANS-037] — An answer says how to read the current version rather than naming one · not guarded +- [`R-ANS-038`][R-ANS-038] — A file-read label names what makes its resource usable · held [R-ANS-001]: ans-001-could-not-ask-never-looks-like-does-not-exist.md [R-ANS-002]: ans-002-the-reason-is-in-the-data-not-only-in-the-text.md @@ -84,3 +85,4 @@ when it is added. [R-ANS-035]: ans-035-an-answer-that-names-a-target-branch-names-the-lines-that-take-a-patch.md [R-ANS-036]: ans-036-a-suite-list-names-the-paths-no-suite-covers.md [R-ANS-037]: ans-037-an-answer-says-how-to-read-the-current-version-rather-than-naming-one.md +[R-ANS-038]: ans-038-a-file-read-label-names-what-makes-its-resource-usable.md diff --git a/requirements/readme.md b/requirements/readme.md index d82487540..3be5feb88 100644 --- a/requirements/readme.md +++ b/requirements/readme.md @@ -126,6 +126,7 @@ test. Both are legitimate, nothing fails on either, and - [`R-ANS-035`][R-ANS-035] — An answer that names a target branch names the lines that take a patch · held - [`R-ANS-036`][R-ANS-036] — A suite list names the paths no suite covers · held - [`R-ANS-037`][R-ANS-037] — An answer says how to read the current version rather than naming one · not guarded +- [`R-ANS-038`][R-ANS-038] — A file-read label names what makes its resource usable · held [R-ANS-001]: answers/ans-001-could-not-ask-never-looks-like-does-not-exist.md [R-ANS-002]: answers/ans-002-the-reason-is-in-the-data-not-only-in-the-text.md @@ -165,6 +166,7 @@ test. Both are legitimate, nothing fails on either, and [R-ANS-035]: answers/ans-035-an-answer-that-names-a-target-branch-names-the-lines-that-take-a-patch.md [R-ANS-036]: answers/ans-036-a-suite-list-names-the-paths-no-suite-covers.md [R-ANS-037]: answers/ans-037-an-answer-says-how-to-read-the-current-version-rather-than-naming-one.md +[R-ANS-038]: answers/ans-038-a-file-read-label-names-what-makes-its-resource-usable.md ### documentation diff --git a/src/Installation/LabelReference.php b/src/Installation/LabelReference.php new file mode 100644 index 000000000..3f57543a2 --- /dev/null +++ b/src/Installation/LabelReference.php @@ -0,0 +1,127 @@ + */ + private const SOURCE_FILES = [ + '*.php', '*.yaml', '*.yml', '*.typoscript', '*.tsconfig', '*.html', + '*.xml', '*.js', '*.mjs', '*.cjs', '*.ts', '*.tsx', '*.json', + ]; + + /** + * @param array}> $resources + * @param array $packages + * @return array> Resource to source paths. + */ + public static function find(array $resources, string $projectRoot, array $packages): array + { + $references = []; + foreach ($resources as $resource) { + $references[$resource['resource']] = $resource['implicitReferences']; + } + + $seen = []; + $roots = array_map( + static fn(string $path): array => ['path' => $path, 'project' => false], + array_values($packages), + ); + $roots[] = ['path' => $projectRoot, 'project' => true]; + + foreach ($roots as $root) { + if (!is_dir($root['path'])) { + continue; + } + $finder = Finder::create() + ->files() + ->in($root['path']) + ->exclude(['.git', '.Build', 'node_modules', 'var']) + ->name(self::SOURCE_FILES) + ->sortByName(); + if ($root['project']) { + $finder->exclude('vendor'); + } + + foreach ($finder as $source) { + $path = $source->getPathname(); + if (isset($seen[$path]) || $source->getSize() > 2_000_000) { + continue; + } + $seen[$path] = true; + $content = file_get_contents($path); + if (!is_string($content)) { + continue; + } + + foreach ($resources as $resource) { + if ($path === $resource['absolute'] || !self::isNamed($content, $resource)) { + continue; + } + $references[$resource['resource']][] = self::displayPath( + $path, + $projectRoot, + $packages, + ); + } + } + } + + foreach ($resources as $resource) { + $configuration = dirname($resource['absolute']) . '/config.yaml'; + if (!is_file($configuration)) { + continue; + } + $content = file_get_contents($configuration); + if (is_string($content) && str_contains($content, basename($resource['absolute']))) { + $references[$resource['resource']][] = self::displayPath( + $configuration, + $projectRoot, + $packages, + ); + } + } + + foreach ($references as &$paths) { + $paths = array_values(array_unique($paths)); + sort($paths); + } + + return $references; + } + + /** @param array{resource: string, domain: string} $resource */ + private static function isNamed(string $content, array $resource): bool + { + if (str_contains($content, $resource['resource'])) { + return true; + } + + $withoutExtension = preg_replace('/\.xlf$/i', '', $resource['resource']); + if (is_string($withoutExtension) && str_contains($content, $withoutExtension)) { + return true; + } + + return $resource['domain'] !== '' && str_contains($content, $resource['domain']); + } + + /** @param array $packages */ + private static function displayPath(string $path, string $projectRoot, array $packages): string + { + foreach ($packages as $key => $package) { + $prefix = rtrim($package, '/') . '/'; + if (str_starts_with($path, $prefix)) { + return 'EXT:' . $key . '/' . substr($path, strlen($prefix)); + } + } + + $prefix = rtrim($projectRoot, '/') . '/'; + + return str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : $path; + } +} diff --git a/src/Installation/Labels.php b/src/Installation/Labels.php index e8ec163f7..eb8406136 100644 --- a/src/Installation/Labels.php +++ b/src/Installation/Labels.php @@ -8,8 +8,8 @@ use TYPO3\DevCompanion\Knowledge\Catalog\TranslationDomain; /** - * The labels shipped by the packages of the discovered installation, read from - * their XLF files. + * The labels shipped by the packages and site configuration of the discovered + * installation, read from their XLF files. * * `language:domain:search` is the better answer and stays the first one asked: * it knows the assembled runtime state, including the overrides an installation @@ -23,36 +23,61 @@ */ final class Labels { - /** Where a package keeps label files, relative to its root. */ + /** Where a package keeps general label files, relative to its root. */ private const LANGUAGE_DIRECTORY = 'Resources/Private/Language'; + /** Where a project keeps its site configuration, relative to its root. */ + private const SITE_DIRECTORY = 'config/sites'; + /** - * Every label of every installed package, or of one of them. + * Every label of every installed package and project site, or of one + * package where an extension key narrows the call. * - * @return array + * @return array, + * location: string + * }> */ public static function all(string $extension = ''): array { - $labels = []; - foreach (Instance::packages() as $key => $path) { - if ($extension !== '' && $key !== $extension) { - continue; + $instance = Instance::describe(); + if ($instance === null) { + return []; + } + + $packages = Instance::packages(); + $files = []; + foreach ($packages as $key => $path) { + if ($extension === '' || $key === $extension) { + array_push($files, ...self::packageFiles($key, $path)); } - foreach (self::files($path) as $file) { - $reference = 'EXT:' . $key . '/' . $file; - $domain = TranslationDomain::fromReference($reference); - if ($domain === null) { - continue; - } - foreach (self::units($path . '/' . $file) as $id => $source) { - $labels[] = [ - 'ref' => $domain . ':' . $id, - 'domain' => $domain, - 'key' => $id, - 'source' => $source, - 'resource' => $reference, - ]; - } + } + if ($extension === '') { + array_push($files, ...self::siteFiles($instance['root'])); + } + + $references = LabelReference::find($files, $instance['root'], $packages); + $labels = []; + foreach ($files as $file) { + foreach (self::units($file['absolute']) as $id => $source) { + $labels[] = [ + 'ref' => $file['domain'] === '' + ? 'LLL:' . $file['resource'] . ':' . $id + : $file['domain'] . ':' . $id, + 'domain' => $file['domain'], + 'key' => $id, + 'source' => $source, + 'resource' => $file['resource'], + 'conventionalName' => $file['conventionalName'], + 'references' => $references[$file['resource']] ?? [], + 'location' => $file['location'], + ]; } } @@ -60,22 +85,29 @@ public static function all(string $extension = ''): array } /** - * The label files of one package, relative to its root: everything below - * Resources/Private/Language/, and the labels.xlf of every site set. + * The label files of one package: everything below the language and site-set + * directories TYPO3's LabelFileResolver searches. * - * @return array + * @return array, + * location: string + * }> */ - private static function files(string $packagePath): array + private static function packageFiles(string $key, string $packagePath): array { $language = $packagePath . '/' . self::LANGUAGE_DIRECTORY; $sets = $packagePath . '/Configuration/Sets'; $found = []; if (is_dir($language)) { - $found[] = Finder::create()->files()->in($language)->depth('< 2')->name('*.xlf')->sortByName(); + $found[] = Finder::create()->files()->in($language)->name('*.xlf')->sortByName(); } if (is_dir($sets)) { - $found[] = Finder::create()->files()->in($sets)->depth(1)->name('labels.xlf')->sortByName(); + $found[] = Finder::create()->files()->in($sets)->name('*.xlf')->sortByName(); } $files = []; @@ -86,13 +118,91 @@ private static function files(string $packagePath): array if (preg_match('/^[a-z]{2}([_-][A-Za-z]{2,3})?\./', $file->getFilename()) === 1) { continue; } - $files[] = substr($file->getPathname(), strlen($packagePath) + 1); + $relative = substr($file->getPathname(), strlen($packagePath) + 1); + $resource = 'EXT:' . $key . '/' . $relative; + $domain = TranslationDomain::fromReference($resource); + if ($domain === null) { + continue; + } + $implicit = []; + if (str_starts_with($relative, 'Configuration/Sets/') + && $file->getFilename() === 'labels.xlf' + && self::usesImplicitLabels($file->getPath() . '/config.yaml')) { + $implicit[] = 'EXT:' . $key . '/' . dirname($relative) . '/config.yaml (implicit labels.xlf)'; + } + $files[] = [ + 'absolute' => $file->getPathname(), + 'resource' => $resource, + 'domain' => $domain, + 'conventionalName' => self::isConventional($relative), + 'implicitReferences' => $implicit, + 'location' => str_starts_with($relative, 'Configuration/Sets/') ? 'site-set' : 'package', + ]; + } + } + + return $files; + } + + /** + * XLF resources kept with project site configuration. TYPO3 does not + * enumerate this directory as package labels, so every one needs an explicit + * reference and the lookup reports that boundary. + * + * @return array, + * location: string + * }> + */ + private static function siteFiles(string $root): array + { + $directory = $root . '/' . self::SITE_DIRECTORY; + if (!is_dir($directory)) { + return []; + } + + $files = []; + foreach (Finder::create()->files()->in($directory)->name('*.xlf')->sortByName() as $file) { + if (preg_match('/^[a-z]{2}([_-][A-Za-z]{2,3})?\./', $file->getFilename()) === 1) { + continue; } + $relative = substr($file->getPathname(), strlen($root) + 1); + $files[] = [ + 'absolute' => $file->getPathname(), + 'resource' => $relative, + 'domain' => '', + 'conventionalName' => $file->getFilename() === 'labels.xlf', + 'implicitReferences' => [], + 'location' => 'project-site', + ]; } return $files; } + private static function isConventional(string $relative): bool + { + if (str_starts_with($relative, 'Configuration/Sets/')) { + return basename($relative) === 'labels.xlf'; + } + + return preg_match('/^locallang(?:_[^.]+)?\.xlf$/', basename($relative)) === 1; + } + + private static function usesImplicitLabels(string $configuration): bool + { + if (!is_file($configuration)) { + return false; + } + $content = file_get_contents($configuration); + + return is_string($content) && preg_match('/^labels\s*:/m', $content) !== 1; + } + /** * The trans-unit ids and source texts of one XLF file. * diff --git a/src/Search/LabelSearch.php b/src/Search/LabelSearch.php index 05564b9f1..241456432 100644 --- a/src/Search/LabelSearch.php +++ b/src/Search/LabelSearch.php @@ -85,9 +85,9 @@ public static function consoleOption(array $terms): string * writes, which a term reaches whole rather than as a substring. That is the * second way of carrying a term, and an item without the field has one. * - * @param array> $items + * @param array> $items * @param array $terms - * @return array> + * @return array> */ public static function carryingEvery(array $items, array $terms): array { @@ -97,7 +97,7 @@ public static function carryingEvery(array $items, array $terms): array return array_values(array_filter($items, static function (array $item) use ($terms): bool { $haystack = self::haystack($item); - $identifiers = mb_strtolower($item['identifiers'] ?? ''); + $identifiers = mb_strtolower((string) ($item['identifiers'] ?? '')); foreach ($terms as $term) { if (!self::carries($haystack, $term) && !self::names($identifiers, $term)) { return false; @@ -116,11 +116,15 @@ public static function carryingEvery(array $items, array $terms): array * alone reached nothing — `D-ANS-041`. What an entry's body names is not * here: an identifier is reached whole, by `names()`. * - * @param array $item + * @param array $item */ private static function haystack(array $item): string { - return mb_strtolower(($item['key'] ?? '') . ' ' . ($item['source'] ?? '') . ' ' . ($item['title'] ?? '')); + return mb_strtolower( + (string) ($item['key'] ?? '') . ' ' + . (string) ($item['source'] ?? '') . ' ' + . (string) ($item['title'] ?? ''), + ); } /** @@ -185,7 +189,7 @@ private static function withoutSeparators(string $text): string * narrows enough is the one to ask with, and the term that reaches nothing * is the one that was misspelled or does not exist here. * - * @param array> $items + * @param array> $items * @param array $terms * @return array */ @@ -207,7 +211,7 @@ public static function perTermCounts(array $items, array $terms): array * and the matching — the same `carries()` `carryingEvery()` and * `perTermCounts()` answer with, identifier spellings included (`D-ANS-016`). * - * @param array> $items + * @param array> $items * @param array $terms * @return array, matchCount: int}> Narrowest first. */ diff --git a/src/Tool/LabelLookup.php b/src/Tool/LabelLookup.php index 6f4af7e19..cae96893d 100644 --- a/src/Tool/LabelLookup.php +++ b/src/Tool/LabelLookup.php @@ -14,7 +14,7 @@ use TYPO3\DevCompanion\Search\LabelSearch; /** - * Labels registered in the installation, answered by the installation. + * Labels registered in the installation or kept in project site configuration. * * The console searches the packages it has active, which is what makes the * answer right: a project extension's labels are in it, and so are the resource @@ -65,7 +65,7 @@ public static function answersFrom(): array public static function description(): string { - return 'Search the labels registered in the TYPO3 installation you are working in. Reuse is local to the translation resource already used at the consuming code: pass resource whenever it is known, and do not reference a match from another module or package merely because its text is identical. The console answers with the resource overrides the installation applies; where it cannot be reached — an installed TYPO3 whose database has no schema yet is the common case — the same packages\' XLF files are read instead. Every match comes back as a translation domain reference; computing that reference for a file this installation does not have, one a patch is about to add, is typo3_translation_domain_lookup.'; + return 'Search the labels registered in the TYPO3 installation you are working in and the XLF files below project config/sites. Reuse is local to the translation resource already used at the consuming code: pass resource whenever it is known, and do not reference a match from another module or package merely because its text is identical. The console answers with the resource overrides the installation applies; the files supply an answer when it cannot be reached and report non-standard names or resources with no static reference. Every match comes back as a translation domain reference; computing that reference for a file this installation does not have, one a patch is about to add, is typo3_translation_domain_lookup.'; } public static function inputSchema(): array @@ -91,9 +91,17 @@ public static function outputSchema(): array 'terms' => Schema::termCounts('How many labels each word of the query reaches on its own, inside the extension and the resource that were asked for — where to narrow when the query as a whole reaches none. A label answers the query only by carrying every word.'), 'termCountsWithoutTheNarrowing' => Schema::termCounts('The same words counted outside the resource, inside the extension that was asked for or derived from it. Returned only where a word reaches there and nothing inside the resource, which makes the resource what emptied this answer rather than the words.'), 'resources' => Schema::listOf(Schema::string(), 'The resources holding a label that carries every word of the query. Returned where a resource was asked for and no label at all in it reaches the query, so a path that was guessed can be replaced by one that exists. Empty means no resource holds such a label.'), + 'resourceDiagnostics' => Schema::listOf(Schema::object([ + 'resource' => Schema::string('The XLF resource this diagnosis describes.'), + 'location' => Schema::string('Where it was found: package, site-set, or project-site.'), + 'conventionalName' => ['type' => 'boolean', 'description' => 'Whether the file follows the naming convention for its location.'], + 'referenced' => ['type' => 'boolean', 'description' => 'Whether an implicit or static reference was found.'], + 'references' => Schema::listOf(Schema::string(), 'Source files that name the resource. A conventional site-set labels.xlf names its adjacent config.yaml as an implicit reference.'), + 'warnings' => Schema::listOf(Schema::string(), 'Naming, discovery, and static-reference warnings for this resource.'), + ], ['resource', 'location', 'conventionalName', 'referenced', 'references', 'warnings'])), 'labels' => Schema::listOf(Schema::object([ - 'ref' => Schema::string('Translation domain reference (package.resource:key) — the canonical form.'), - 'domain' => Schema::string(), + 'ref' => Schema::string('The reusable label reference: a translation domain for package labels or an LLL file reference for project-site labels.'), + 'domain' => Schema::string('The translation domain, empty for a project-site XLF that TYPO3 does not register as a package resource.'), 'key' => Schema::string('The trans-unit id.'), 'source' => Schema::string('The label text in the searched locale.'), 'resource' => Schema::string('The XLF file it lives in.'), @@ -138,13 +146,19 @@ public static function answer(array $args): ToolResult $establishedNone = $answer['exitCode'] === 0 && str_contains($answer['output'], self::NOTHING_MATCHED); + $fileCandidates = Labels::all($extension); + $fileResources = []; + foreach ($fileCandidates as $label) { + $fileResources[$label['resource']] = $label; + } + $answeredBy = 'installation'; $candidates = []; if (!is_array($answer['data']) && !$establishedNone) { // The labels are in the packages' files whether or not the console // boots, and it needs a migrated database to boot. A weaker answer // beats none, as long as it says which one it is. - $candidates = Labels::all($extension); + $candidates = $fileCandidates; if ($candidates === []) { return Unsupported::because( self::whyNothingWasEstablished($answer), @@ -158,15 +172,34 @@ public static function answer(array $args): ToolResult $data = is_array($answer['data']) ? $answer['data'] : []; foreach ($data['items'] ?? [] as $item) { foreach ($item['labels'] ?? [] as $label) { - $candidates[] = [ + $candidate = [ 'ref' => (string) $label['domain'] . ':' . (string) $label['reference'], 'domain' => (string) $label['domain'], 'key' => (string) $label['reference'], 'source' => (string) $label['label'], 'resource' => (string) ($item['resource'] ?? ''), + 'origin' => 'installation', ]; + $metadata = $fileResources[$candidate['resource']] ?? null; + if (is_array($metadata)) { + $candidate += [ + 'conventionalName' => $metadata['conventionalName'], + 'references' => $metadata['references'], + 'location' => $metadata['location'], + ]; + } + $candidates[] = $candidate; + } + } + if (is_array($answer['data']) || $establishedNone) { + foreach ($fileCandidates as $label) { + if ($label['location'] === 'project-site') { + $label['origin'] = 'packages'; + $candidates[] = $label; + } } } + $candidates = self::uniqueLabels($candidates); // Kept, because a miss cannot say whether the resource or the words // emptied it once the resource has taken the labels away — `D-ANS-016`. @@ -185,15 +218,30 @@ public static function answer(array $args): ToolResult $total = count($labels); $shown = array_slice($labels, 0, $limit); + if ($shown !== [] && array_filter( + $shown, + static fn(array $label): bool => ($label['origin'] ?? 'packages') === 'installation', + ) === []) { + $answeredBy = 'packages'; + } + $diagnostics = self::resourceDiagnostics($shown !== [] ? $shown : ($resource !== '' ? $candidates : [])); $instance = Instance::describe(); $fromFiles = $answeredBy === 'packages' ? sprintf( - "\n\nRead from the XLF files of the installed packages: %s (%s). " + "\n\nRead from the XLF files of the installed packages and project site configuration: %s (%s). " . 'What that leaves out is the assembled runtime state — a label an installation replaces through ' . 'LANG/resourceOverrides is shown here as its package ships it.', $answer['exitCode'] !== 0 ? 'the console could not be asked' : 'the console settled nothing', self::whyNothingWasEstablished($answer), ) : ''; + $fromProjectSites = $answeredBy === 'installation' + && array_filter( + $shown, + static fn(array $label): bool => ($label['location'] ?? '') === 'project-site', + ) !== [] + ? "\n\nProject-site XLF files were read beside the console result because TYPO3 does not enumerate config/sites as package labels." + : ''; + $diagnosticText = self::diagnosticText($diagnostics); $reuseBoundary = $resource === '' ? "\n\nA match is reusable only when its resource is the one already used at the consuming code. " . 'A label from another module or package is not a shared vocabulary merely because its text matches; ' @@ -278,6 +326,7 @@ public static function answer(array $args): ToolResult 'matchCount' => 0, 'labels' => [], 'terms' => $termCounts, + 'resourceDiagnostics' => $diagnostics, 'answeredBy' => $answeredBy, ]; // Each field is present where it was computed and absent where @@ -293,7 +342,8 @@ public static function answer(array $args): ToolResult } return ToolResult::create( - implode("\n", $lines) . $reuseBoundary . self::SOURCE_LANGUAGE . $fromFiles, + implode("\n", $lines) . $reuseBoundary . self::SOURCE_LANGUAGE . $fromFiles + . $fromProjectSites . $diagnosticText, $data, ); } @@ -311,17 +361,112 @@ public static function answer(array $args): ToolResult $lines[] = ' ' . $label['resource']; } $lines[] = ''; - $lines[] = 'Reference a label by the domain form shown first (package.resource:key) — in TCA, in ' - . 'LanguageService::sL(), and in f:translate as separate domain and key attributes.'; - - return ToolResult::create(implode("\n", $lines) . $reuseBoundary . self::SOURCE_LANGUAGE . $fromFiles, [ - 'query' => $query, - 'resource' => $resource === '' ? null : $resource, - 'matchCount' => $total, - 'labels' => $shown, - 'terms' => $termCounts, - 'answeredBy' => $answeredBy, - ]); + $lines[] = 'Reference a label by the ref shown first. Package resources use a translation domain; ' + . 'project-site resources use the full LLL file reference.'; + + return ToolResult::create(implode("\n", $lines) . $reuseBoundary . self::SOURCE_LANGUAGE . $fromFiles + . $fromProjectSites . $diagnosticText, [ + 'query' => $query, + 'resource' => $resource === '' ? null : $resource, + 'matchCount' => $total, + 'labels' => array_map(self::publicLabel(...), $shown), + 'terms' => $termCounts, + 'resourceDiagnostics' => $diagnostics, + 'answeredBy' => $answeredBy, + ]); + } + + /** + * @param array> $labels + * @return array> + */ + private static function uniqueLabels(array $labels): array + { + $unique = []; + foreach ($labels as $label) { + $identity = (string) ($label['resource'] ?? '') . "\0" . (string) ($label['key'] ?? ''); + $unique[$identity] ??= $label; + } + + return array_values($unique); + } + + /** + * @param array $label + * @return array{ref: string, domain: string, key: string, source: string, resource: string} + */ + private static function publicLabel(array $label): array + { + return [ + 'ref' => (string) $label['ref'], + 'domain' => (string) $label['domain'], + 'key' => (string) $label['key'], + 'source' => (string) $label['source'], + 'resource' => (string) $label['resource'], + ]; + } + + /** + * @param array> $labels + * @return array, + * warnings: array + * }> + */ + private static function resourceDiagnostics(array $labels): array + { + $diagnostics = []; + foreach ($labels as $label) { + if (!isset($label['conventionalName'], $label['references'], $label['location'])) { + continue; + } + $resource = (string) $label['resource']; + if (isset($diagnostics[$resource])) { + continue; + } + $references = array_values(array_map('strval', (array) $label['references'])); + $location = (string) $label['location']; + $warnings = []; + if ($location === 'project-site') { + $warnings[] = 'TYPO3 does not register XLF files below config/sites automatically; keep an explicit LLL reference to this resource.'; + } + if (!$label['conventionalName']) { + $warnings[] = $location === 'package' + ? 'The conventional package language file name is locallang.xlf or locallang_.xlf.' + : 'The conventional site label file name is labels.xlf.'; + } + if ($references === []) { + $warnings[] = 'No static reference to this resource was found; references assembled at runtime are outside this scan.'; + } + $diagnostics[$resource] = [ + 'resource' => $resource, + 'location' => $location, + 'conventionalName' => (bool) $label['conventionalName'], + 'referenced' => $references !== [], + 'references' => $references, + 'warnings' => $warnings, + ]; + } + ksort($diagnostics); + + return array_values($diagnostics); + } + + /** @param array}> $diagnostics */ + private static function diagnosticText(array $diagnostics): string + { + $lines = []; + foreach ($diagnostics as $diagnostic) { + foreach ($diagnostic['warnings'] as $warning) { + $lines[] = '- ' . $diagnostic['resource'] . ': ' . $warning; + } + } + + return $lines === [] ? '' : "\n\nResource warnings:\n" . implode("\n", $lines); } /** diff --git a/tests/Unit/LabelSearchTest.php b/tests/Unit/LabelSearchTest.php index 8611b8272..07bb50100 100644 --- a/tests/Unit/LabelSearchTest.php +++ b/tests/Unit/LabelSearchTest.php @@ -532,6 +532,92 @@ public static function whatTheConsoleAnswers(): array ]; } + #[Requirement('R-ANS-038')] + #[Test] + public function aProjectSiteLabelFileIsReadBesideAnEmptyConsoleAnswer(): void + { + $this->consoleThatPrints("Labels in active extensions\n===\n\n [WARNING] No language resource files found.\n"); + $this->projectLabelFile('config/sites/main/labels.xlf', ['site.title' => 'Site title']); + + $result = Registry::call('typo3_label_lookup', ['query' => 'site title']); + + self::assertSame('packages', $result->data['answeredBy']); + self::assertSame('LLL:config/sites/main/labels.xlf:site.title', $result->data['labels'][0]['ref']); + self::assertSame('project-site', $result->data['resourceDiagnostics'][0]['location']); + self::assertStringContainsString('does not register XLF files below config/sites automatically', $result->text); + } + + #[Requirement('R-ANS-038')] + #[Test] + public function aNonStandardLabelFileIsWarned(): void + { + $this->consoleThatFails('The console cannot boot'); + $this->labelFile('Resources/Private/Language/BackendLabels.xlf', ['button.save' => 'Save changes']); + + $result = Registry::call('typo3_label_lookup', ['query' => 'save changes']); + + self::assertFalse($result->data['resourceDiagnostics'][0]['conventionalName']); + self::assertStringContainsString( + 'The conventional package language file name is locallang.xlf or locallang_.xlf.', + $result->text, + ); + } + + #[Decision('D-ANS-134')] + #[Requirement('R-ANS-038')] + #[Test] + public function aStaticReferenceIsNamed(): void + { + $this->consoleThatFails('The console cannot boot'); + $this->labelFile('Resources/Private/Language/locallang_feature.xlf', ['feature.title' => 'Feature title']); + $reference = $this->installationRoot . '/typo3/sysext/core/Configuration/TCA/Overrides/pages.php'; + mkdir(dirname($reference), 0o777, true); + file_put_contents($reference, " 'core.feature:feature.title'];"); + + $result = Registry::call('typo3_label_lookup', ['query' => 'feature title']); + + self::assertTrue($result->data['resourceDiagnostics'][0]['referenced']); + self::assertSame( + ['EXT:core/Configuration/TCA/Overrides/pages.php'], + $result->data['resourceDiagnostics'][0]['references'], + ); + self::assertStringNotContainsString('No static reference', $result->text); + } + + #[Decision('D-ANS-134')] + #[Requirement('R-ANS-038')] + #[Test] + public function anUnreferencedResourceStaysVisible(): void + { + $this->consoleThatFails('The console cannot boot'); + $this->labelFile('Resources/Private/Language/locallang_orphan.xlf', ['orphan.title' => 'Orphan title']); + + $result = Registry::call('typo3_label_lookup', ['query' => 'orphan title']); + + self::assertSame(1, $result->data['matchCount']); + self::assertFalse($result->data['resourceDiagnostics'][0]['referenced']); + self::assertStringContainsString('references assembled at runtime are outside this scan', $result->text); + } + + #[Requirement('R-ANS-038')] + #[Test] + public function aSiteSetLabelsFileCarriesItsImplicitReference(): void + { + $this->consoleThatFails('The console cannot boot'); + $this->labelFile('Configuration/Sets/Feature/labels.xlf', ['feature.name' => 'Feature name']); + $configuration = $this->installationRoot . '/typo3/sysext/core/Configuration/Sets/Feature/config.yaml'; + file_put_contents($configuration, "name: core/feature\n"); + + $result = Registry::call('typo3_label_lookup', ['query' => 'feature name']); + + self::assertTrue($result->data['resourceDiagnostics'][0]['referenced']); + self::assertSame( + ['EXT:core/Configuration/Sets/Feature/config.yaml (implicit labels.xlf)'], + $result->data['resourceDiagnostics'][0]['references'], + ); + self::assertSame([], $result->data['resourceDiagnostics'][0]['warnings']); + } + #[Requirement('R-ANS-008')] #[Test] public function aConsoleThatCannotBootIsAnsweredFromTheFilesItWouldHaveRead(): void @@ -575,7 +661,13 @@ public function aDatabaseWithoutASchemaIsNamed(): void /** @param array $units */ private function labelFile(string $path, array $units): void { - $file = $this->installationRoot . '/typo3/sysext/core/' . $path; + $this->projectLabelFile('typo3/sysext/core/' . $path, $units); + } + + /** @param array $units */ + private function projectLabelFile(string $path, array $units): void + { + $file = $this->installationRoot . '/' . $path; mkdir(dirname($file), 0o777, true); $body = '';