Skip to content

Commit bcb8e94

Browse files
committed
[FEATURE] Add opt-in documentation lint (PoC for #1157)
Adds a proof-of-concept documentation linter that hooks into the compiler pipeline as a node transformer. As a first rule it warns when a section heading contains a discouraged phrase (e.g. "Non-Composer mode"). The linter is opt-in (the `lint` theme setting, default off) and emits warnings only, so it never breaks or rejects third-party extension documentation unless a caller explicitly enables it and opts into --fail-on-log. This is deliberate: there is no reliable back-channel to third-party extension authors to tell them their docs started failing a newly added rule. Matching is case-insensitive and word-bounded so a short phrase does not match inside a larger word. The configurable phrase list is trimmed and de-duplicated. Tests: - Unit tests for the opt-in toggle, phrase parsing (trim/empty/dedup), case-insensitivity and word-boundary matching (no false positives). - Integration tests for the enabled (warning emitted) and disabled (no warning, opt-in respected) cases. Refs #1157 Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
1 parent 012135d commit bcb8e94

10 files changed

Lines changed: 256 additions & 0 deletions

File tree

packages/typo3-docs-theme/resources/config/typo3-docs-theme.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\CollectFileObjectsTransformer;
2323
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\CollectPrefixLinkTargetsTransformer;
2424
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\ConfvalMenuNodeTransformer;
25+
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\LintDiscouragedPhrasesTransformer;
2526
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\RedirectsNodeTransformer;
2627
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\RemoveInterlinkSelfReferencesFromCrossReferenceNodeTransformer;
2728
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\ReplacePermalinksNodeTransformer;
@@ -116,6 +117,8 @@
116117
->tag('phpdoc.guides.compiler.nodeTransformers')
117118
->set(Typo3TalkNodeTransformer::class)
118119
->tag('phpdoc.guides.compiler.nodeTransformers')
120+
->set(LintDiscouragedPhrasesTransformer::class)
121+
->tag('phpdoc.guides.compiler.nodeTransformers')
119122
->set(TwigExtension::class)
120123
->tag('twig.extension')
121124
->autowire()
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of phpDocumentor.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*
11+
* @link https://phpdoc.org
12+
*/
13+
14+
namespace T3Docs\Typo3DocsTheme\Compiler\NodeTransformers;
15+
16+
use phpDocumentor\Guides\Compiler\CompilerContextInterface;
17+
use phpDocumentor\Guides\Compiler\NodeTransformer;
18+
use phpDocumentor\Guides\Nodes\Node;
19+
use phpDocumentor\Guides\Nodes\SectionNode;
20+
use Psr\Log\LoggerInterface;
21+
use T3Docs\Typo3DocsTheme\Settings\Typo3DocsThemeSettings;
22+
23+
use function array_filter;
24+
use function array_map;
25+
use function array_unique;
26+
use function array_values;
27+
use function explode;
28+
use function in_array;
29+
use function preg_match;
30+
use function preg_quote;
31+
use function sprintf;
32+
use function strtolower;
33+
use function trim;
34+
35+
/**
36+
* Proof-of-concept documentation linter for issue #1157.
37+
*
38+
* This transformer demonstrates how a documentation-content lint rule can be
39+
* hooked into the render pipeline: it traverses section headings and emits a
40+
* warning when a heading contains a discouraged phrase (e.g. "Non-Composer
41+
* mode").
42+
*
43+
* Two deliberate design choices, both driven by the fact that render-guides
44+
* renders *third-party* extension documentation whose authors we have no
45+
* reliable back-channel to:
46+
*
47+
* 1. Opt-in: the rule does nothing unless the `lint` theme setting is truthy.
48+
* A failing lint must never silently break or reject an extension's docs
49+
* just because the rule set changed.
50+
* 2. Warning severity: findings are logged as warnings, so they only abort a
51+
* render when the caller explicitly passes `--fail-on-log`. By default they
52+
* are advisory.
53+
*
54+
* Matching is case-insensitive and bound to whole words, so a short phrase does
55+
* not match inside a larger word (e.g. "id" does not flag "Identifier").
56+
*
57+
* @implements NodeTransformer<SectionNode>
58+
*/
59+
final class LintDiscouragedPhrasesTransformer implements NodeTransformer
60+
{
61+
/** @var list<string> */
62+
private const DEFAULT_DISCOURAGED_PHRASES = ['Non-Composer mode'];
63+
64+
public function __construct(
65+
private readonly Typo3DocsThemeSettings $themeSettings,
66+
private readonly LoggerInterface $logger,
67+
) {}
68+
69+
public function enterNode(Node $node, CompilerContextInterface $compilerContext): Node
70+
{
71+
if (!$node instanceof SectionNode || !$this->isLintEnabled()) {
72+
return $node;
73+
}
74+
75+
$heading = $node->getTitle()->toString();
76+
77+
foreach ($this->getDiscouragedPhrases() as $phrase) {
78+
if (preg_match('/\b' . preg_quote($phrase, '/') . '\b/iu', $heading) !== 1) {
79+
continue;
80+
}
81+
$this->logger->warning(
82+
sprintf('Heading "%s" contains the discouraged phrase "%s".', $heading, $phrase),
83+
$compilerContext->getLoggerInformation(),
84+
);
85+
}
86+
87+
return $node;
88+
}
89+
90+
public function leaveNode(Node $node, CompilerContextInterface $compilerContext): Node
91+
{
92+
return $node;
93+
}
94+
95+
public function supports(Node $node): bool
96+
{
97+
return $node instanceof SectionNode;
98+
}
99+
100+
public function getPriority(): int
101+
{
102+
// Read-only pass; ordering relative to other transformers is irrelevant.
103+
return 1000;
104+
}
105+
106+
private function isLintEnabled(): bool
107+
{
108+
return in_array(
109+
strtolower($this->themeSettings->getSettings('lint', 'false')),
110+
['1', 'true', 'yes', 'on'],
111+
true,
112+
);
113+
}
114+
115+
/** @return list<string> */
116+
private function getDiscouragedPhrases(): array
117+
{
118+
$configured = trim($this->themeSettings->getSettings('lint_discouraged_phrases', ''));
119+
if ($configured === '') {
120+
return self::DEFAULT_DISCOURAGED_PHRASES;
121+
}
122+
123+
$phrases = array_filter(array_map(trim(...), explode(',', $configured)), static fn(string $phrase): bool => $phrase !== '');
124+
125+
return array_values(array_unique($phrases));
126+
}
127+
}

packages/typo3-docs-theme/src/DependencyInjection/Typo3DocsThemeExtension.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ public function load(array $configs, ContainerBuilder $container): void
7171
'typo3_core_preferred' => $this->getConfigValue($configs, 'typo3_core_preferred', ''),
7272
'confval_default' => $this->getConfigValue($configs, 'confval_default', 'Option'),
7373
'disable_version_switch' => $this->getConfigValue($configs, 'disable_version_switch', ''),
74+
'lint' => $this->getConfigValue($configs, 'lint', 'false'),
75+
'lint_discouraged_phrases' => $this->getConfigValue($configs, 'lint_discouraged_phrases', ''),
7476
],
7577
],
7678
);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use phpDocumentor\Guides\Compiler\CompilerContextInterface;
6+
use phpDocumentor\Guides\Nodes\InlineCompoundNode;
7+
use phpDocumentor\Guides\Nodes\Inline\PlainTextInlineNode;
8+
use phpDocumentor\Guides\Nodes\SectionNode;
9+
use phpDocumentor\Guides\Nodes\TitleNode;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use PHPUnit\Framework\Attributes\Test;
12+
use PHPUnit\Framework\TestCase;
13+
use Psr\Log\AbstractLogger;
14+
use Stringable;
15+
use T3Docs\Typo3DocsTheme\Compiler\NodeTransformers\LintDiscouragedPhrasesTransformer;
16+
use T3Docs\Typo3DocsTheme\Settings\Typo3DocsThemeSettings;
17+
18+
final class LintDiscouragedPhrasesTransformerTest extends TestCase
19+
{
20+
/**
21+
* @param array<string, string> $settings
22+
* @param list<string> $expectedPhrases the discouraged phrases expected to be reported for $heading
23+
*/
24+
#[Test]
25+
#[DataProvider('lintProvider')]
26+
public function reportsDiscouragedPhrasesInHeadings(array $settings, string $heading, array $expectedPhrases): void
27+
{
28+
$logger = new class () extends AbstractLogger {
29+
/** @var list<string> */
30+
public array $warnings = [];
31+
32+
/** @param mixed[] $context */
33+
public function log($level, string|Stringable $message, array $context = []): void
34+
{
35+
if ($level === 'warning') {
36+
$this->warnings[] = (string) $message;
37+
}
38+
}
39+
};
40+
41+
$transformer = new LintDiscouragedPhrasesTransformer(new Typo3DocsThemeSettings($settings), $logger);
42+
43+
$section = new SectionNode(new TitleNode(new InlineCompoundNode([new PlainTextInlineNode($heading)]), 1, 'heading-id'));
44+
$transformer->enterNode($section, self::createMock(CompilerContextInterface::class));
45+
46+
self::assertCount(count($expectedPhrases), $logger->warnings);
47+
foreach ($expectedPhrases as $phrase) {
48+
self::assertNotEmpty(
49+
array_filter($logger->warnings, static fn(string $w): bool => str_contains($w, '"' . $phrase . '"')),
50+
sprintf('Expected a warning for phrase "%s", got: %s', $phrase, implode(' | ', $logger->warnings)),
51+
);
52+
}
53+
}
54+
55+
/**
56+
* @return iterable<string, array{array<string, string>, string, list<string>}>
57+
*/
58+
public static function lintProvider(): iterable
59+
{
60+
// Opt-in: nothing happens unless `lint` is truthy.
61+
yield 'disabled by default' => [[], 'Installing in Non-Composer mode', []];
62+
yield 'disabled explicitly' => [['lint' => 'false'], 'Installing in Non-Composer mode', []];
63+
yield 'disabled on garbage token' => [['lint' => 'enabled'], 'Installing in Non-Composer mode', []];
64+
yield 'enabled via true' => [['lint' => 'true'], 'Installing in Non-Composer mode', ['Non-Composer mode']];
65+
yield 'enabled via 1' => [['lint' => '1'], 'Installing in Non-Composer mode', ['Non-Composer mode']];
66+
yield 'enabled via yes' => [['lint' => 'yes'], 'Installing in Non-Composer mode', ['Non-Composer mode']];
67+
yield 'enabled case-insensitive token' => [['lint' => 'TRUE'], 'Installing in Non-Composer mode', ['Non-Composer mode']];
68+
69+
// Default phrase only triggers when present.
70+
yield 'no discouraged phrase' => [['lint' => 'true'], 'Installing with Composer', []];
71+
72+
// Case-insensitive phrase matching.
73+
yield 'case-insensitive heading' => [['lint' => 'true'], 'The NON-COMPOSER MODE chapter', ['Non-Composer mode']];
74+
75+
// Word-boundary matching: no false positive inside a larger word.
76+
yield 'word boundary no false positive' => [['lint' => 'true', 'lint_discouraged_phrases' => 'id'], 'The Identifier field', []];
77+
yield 'word boundary real match' => [['lint' => 'true', 'lint_discouraged_phrases' => 'id'], 'The id field', ['id']];
78+
79+
// Custom phrase list replaces the default.
80+
yield 'custom list replaces default' => [['lint' => 'true', 'lint_discouraged_phrases' => 'foo, bar'], 'Non-Composer mode is fine here', []];
81+
yield 'custom list matches' => [['lint' => 'true', 'lint_discouraged_phrases' => 'foo, bar'], 'foo and bar', ['foo', 'bar']];
82+
83+
// Whitespace and empty fragments are ignored; duplicates are de-duplicated (one warning, not two).
84+
yield 'whitespace and empty fragments' => [['lint' => 'true', 'lint_discouraged_phrases' => ' foo , , bar '], 'foo bar', ['foo', 'bar']];
85+
yield 'duplicate phrase warns once' => [['lint' => 'true', 'lint_discouraged_phrases' => 'foo, foo'], 'foo here', ['foo']];
86+
}
87+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<!-- content start -->
2+
<section class="section" id="installing-in-non-composer-mode">
3+
<h1>Installing in Non-Composer mode<a class="headerlink" href="#installing-in-non-composer-mode" data-bs-toggle="modal" data-bs-target="#linkReferenceModal" title="Reference this headline">&nbsp;<i class="fa-solid fa-link"></i></a></h1>
4+
5+
<p>Body text.</p>
6+
7+
</section>
8+
<!-- content end -->
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
===============================
2+
Installing in Non-Composer mode
3+
===============================
4+
5+
Body text.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<!-- content start -->
2+
<section class="section" id="installing-in-non-composer-mode">
3+
<h1>Installing in Non-Composer mode<a class="headerlink" href="#installing-in-non-composer-mode" data-bs-toggle="modal" data-bs-target="#linkReferenceModal" title="Reference this headline">&nbsp;<i class="fa-solid fa-link"></i></a></h1>
4+
5+
<p>Body text.</p>
6+
7+
</section>
8+
<!-- content end -->
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
app.WARNING: Heading "Installing in Non-Composer mode" contains the discouraged phrase "Non-Composer mode". {"rst-file":"index.rst"} []
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<guides xmlns="https://www.phpdoc.org/guides"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="https://www.phpdoc.org/guides vendor/phpdocumentor/guides-cli/resources/schema/guides.xsd"
5+
links-are-relative="true"
6+
>
7+
<extension class="\T3Docs\Typo3DocsTheme\DependencyInjection\Typo3DocsThemeExtension"
8+
lint="true"
9+
/>
10+
</guides>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
===============================
2+
Installing in Non-Composer mode
3+
===============================
4+
5+
Body text.

0 commit comments

Comments
 (0)