2727
2828namespace WordPress \PHPStan ;
2929
30- use FilesystemIterator ;
3130use PhpParser \Comment \Doc ;
3231use PhpParser \Error as PhpParserError ;
3332use PhpParser \ErrorHandler \Collecting ;
4241use PhpParser \NodeFinder ;
4342use PhpParser \NodeTraverser ;
4443use PhpParser \ParserFactory ;
44+ use PHPStan \Analyser \DependencyTracker ;
4545use PHPStan \Analyser \Scope ;
4646use PHPStan \PhpDoc \ResolvedPhpDocBlock ;
4747use PHPStan \ShouldNotHappenException ;
4848use PHPStan \Type \FileTypeMapper ;
49- use RecursiveDirectoryIterator ;
50- use RecursiveIteratorIterator ;
51- use SplFileInfo ;
5249
5350/**
5451 * Bridges the docblock attached to a hook call by HookDocsVisitor to PHPStan's
@@ -104,18 +101,6 @@ class HookDocBlock {
104101 'apply_filters_ref_array ' ,
105102 );
106103
107- /**
108- * Directories, relative to the WordPress root, scanned for reference comments
109- * when hashing the docblocks that call sites can inherit.
110- *
111- * @see HookDocBlock::getScannedFiles()
112- */
113- private const SCANNED_DIRECTORIES = array (
114- 'wp-admin ' ,
115- 'wp-includes ' ,
116- 'wp-content/themes ' ,
117- );
118-
119104 /**
120105 * Problem code: the referenced file does not exist.
121106 */
@@ -178,110 +163,6 @@ public function __construct( FileTypeMapper $file_type_mapper, ?string $wordpres
178163 $ this ->canonicalWordpressRoot = str_replace ( '\\' , '/ ' , $ this ->canonicalWordpressRoot );
179164 }
180165
181- /**
182- * Returns a hash of every hook docblock that a call site can inherit through a
183- * "documented elsewhere" reference comment.
184- *
185- * Those docblocks are read with plain file I/O, so PHPStan's dependency graph
186- * does not know that the referencing files depend on them: editing a canonical
187- * docblock re-analyzes only the file it lives in, leaving the cached results of
188- * every referencing file in place. Folding this hash into the result cache key
189- * invalidates the cache when an inheritable docblock changes, and only then.
190- *
191- * @see HookDocsResultCacheMetaExtension
192- *
193- * @return non-falsy-string
194- */
195- public function getReferencedHookDocsHash (): string {
196- $ docs = array ();
197-
198- foreach ( $ this ->getScannedFiles () as $ file ) {
199- $ code = file_get_contents ( $ file );
200-
201- if ( false === $ code || ! str_contains ( $ code , 'is documented in ' ) ) {
202- continue ;
203- }
204-
205- if ( ! preg_match_all ( self ::REFERENCE_PATTERN , $ code , $ matches ) ) {
206- continue ;
207- }
208-
209- foreach ( $ matches [1 ] as $ reference_path ) {
210- $ target = $ this ->resolveReferencePath ( $ file , $ reference_path );
211-
212- if ( null === $ target ) {
213- continue ;
214- }
215-
216- // Key on the path relative to the WordPress root so the hash does not
217- // depend on where the checkout lives.
218- $ key = $ this ->getRootRelativePath ( $ target );
219-
220- $ docs [ $ key ] ??= $ this ->getHookDocs ( $ target );
221- }
222- }
223-
224- ksort ( $ docs );
225-
226- return md5 ( (string ) json_encode ( $ docs ) );
227- }
228-
229- /**
230- * Returns the files scanned for reference comments: the WordPress directories
231- * that can contain them, plus the PHP files at the root of the install.
232- *
233- * `wp-content/plugins` is deliberately not scanned. A checkout may have plugins
234- * carrying large `vendor` and `node_modules` trees, and core's reference comments
235- * only ever point within core.
236- *
237- * @return list<string> Absolute file paths.
238- */
239- private function getScannedFiles (): array {
240- $ files = array ();
241-
242- foreach ( self ::SCANNED_DIRECTORIES as $ directory ) {
243- $ path = $ this ->wordpressRoot . '/ ' . $ directory ;
244-
245- if ( ! is_dir ( $ path ) ) {
246- continue ;
247- }
248-
249- $ iterator = new RecursiveIteratorIterator (
250- new RecursiveDirectoryIterator ( $ path , FilesystemIterator::SKIP_DOTS )
251- );
252-
253- foreach ( $ iterator as $ file ) {
254- if ( $ file instanceof SplFileInfo && $ file ->isFile () && 'php ' === strtolower ( $ file ->getExtension () ) ) {
255- $ files [] = $ file ->getPathname ();
256- }
257- }
258- }
259-
260- $ root_files = glob ( $ this ->wordpressRoot . '/*.php ' );
261-
262- if ( is_array ( $ root_files ) ) {
263- $ files = array_merge ( $ files , $ root_files );
264- }
265-
266- return $ files ;
267- }
268-
269- /**
270- * Expresses an absolute path relative to the WordPress root when it sits inside
271- * it, so that hashes do not depend on the checkout location.
272- *
273- * Paths reached through a reference comment are canonical, so the canonical root is
274- * what they are relative to.
275- *
276- * @param string $path Absolute path.
277- * @return string
278- */
279- private function getRootRelativePath ( string $ path ): string {
280- $ prefix = $ this ->canonicalWordpressRoot . '/ ' ;
281-
282- return str_starts_with ( $ path , $ prefix ) ? substr ( $ path , strlen ( $ prefix ) ) : $ path ;
283- }
284-
285166 /**
286167 * Resolves the documentation for a hook call: the docblock written above it, or
287168 * the canonical docblock a "documented elsewhere" comment points at.
@@ -295,8 +176,14 @@ private function getRootRelativePath( string $path ): string {
295176 * parameters. `problem` says why it could not be resolved, when the reason is one
296177 * worth reporting.
297178 *
298- * @param FuncCall $function_call Hook function call node.
299- * @param Scope $scope Analysis scope.
179+ * A canonical docblock is read with plain file I/O, so PHPStan's dependency graph
180+ * cannot see that the referencing file depends on it. Resolving a reference
181+ * therefore declares that dependency on the scope, and the result cache
182+ * re-analyzes the referencing file when the file it resolved to changes, or when
183+ * a file it would resolve to first is created.
184+ *
185+ * @param FuncCall $function_call Hook function call node.
186+ * @param Scope&DependencyTracker $scope Analysis scope.
300187 * @return HookDocumentation|null Null when no docblock precedes the call.
301188 * @throws ShouldNotHappenException
302189 */
@@ -336,7 +223,7 @@ public function getHookDoc( FuncCall $function_call, Scope $scope ): ?array {
336223 }
337224
338225 $ reference_path = $ matches [1 ];
339- $ target_file = $ this ->resolveReferencePath ( $ scope ->getFile (), $ reference_path );
226+ $ target_file = $ this ->resolveReferencePath ( $ scope ->getFile (), $ reference_path, $ scope );
340227
341228 // The referenced file could not be located up the directory tree.
342229 if ( null === $ target_file ) {
@@ -378,8 +265,8 @@ public function getHookDoc( FuncCall $function_call, Scope $scope ): ?array {
378265 /**
379266 * Resolves the docblock preceding the given function call, if any.
380267 *
381- * @param FuncCall $function_call Hook function call node.
382- * @param Scope $scope Analysis scope.
268+ * @param FuncCall $function_call Hook function call node.
269+ * @param Scope&DependencyTracker $scope Analysis scope.
383270 * @return ResolvedPhpDocBlock|null Resolved docblock, or null when none precedes
384271 * the call or a reference cannot be resolved.
385272 * @throws ShouldNotHappenException
@@ -790,16 +677,21 @@ private static function hookNameRegexParts( Expr $expr ): ?array {
790677 *
791678 * Only the single named file is ever tested; no directory is enumerated.
792679 *
793- * @param string $current_file Absolute path to the file with the reference comment.
794- * @param string $reference_path Root-relative path (e.g. "wp-includes/media.php").
680+ * Every candidate tested is declared as a dependency of the file being analyzed,
681+ * not only the one that resolves. A candidate tested before the hit decides the
682+ * result by being absent, so creating it changes which file the reference names.
683+ *
684+ * @param string $current_file Absolute path to the file with the reference comment.
685+ * @param string $reference_path Root-relative path (e.g. "wp-includes/media.php").
686+ * @param DependencyTracker $tracker Tracker for the file being analyzed.
795687 * @return string|null Absolute path to the referenced file, or null when it cannot be located.
796688 */
797- private function resolveReferencePath ( string $ current_file , string $ reference_path ): ?string {
689+ private function resolveReferencePath ( string $ current_file , string $ reference_path, DependencyTracker $ tracker ): ?string {
798690 $ reference_path = ltrim ( $ reference_path , '/ ' );
799691 $ dir = dirname ( $ current_file );
800692
801693 while ( $ dir === $ this ->wordpressRoot || str_starts_with ( $ dir , $ this ->wordpressRoot . '/ ' ) ) {
802- $ target = $ this ->resolveWithinRoot ( $ dir . '/ ' . $ reference_path );
694+ $ target = $ this ->resolveWithinRoot ( $ dir . '/ ' . $ reference_path, $ tracker );
803695 if ( null !== $ target ) {
804696 return $ target ;
805697 }
@@ -813,7 +705,7 @@ private function resolveReferencePath( string $current_file, string $reference_p
813705 }
814706
815707 // The file holding the comment is not in the tree, so resolve against the root.
816- return $ this ->resolveWithinRoot ( $ this ->wordpressRoot . '/ ' . $ reference_path );
708+ return $ this ->resolveWithinRoot ( $ this ->wordpressRoot . '/ ' . $ reference_path, $ tracker );
817709 }
818710
819711 /**
@@ -830,10 +722,17 @@ private function resolveReferencePath( string $current_file, string $reference_p
830722 * them working while ensuring a reference cannot reach a file outside it. That
831723 * matters because whatever resolution finds is then read and parsed.
832724 *
833- * @param string $candidate Absolute candidate path, possibly containing dot segments.
725+ * The candidate is tracked as given rather than canonicalized. It need not exist,
726+ * and when it does, its contents are hashed through any symlink or dot segment,
727+ * which is the file that is read.
728+ *
729+ * @param string $candidate Absolute candidate path, possibly containing dot segments.
730+ * @param DependencyTracker $tracker Tracker for the file being analyzed.
834731 * @return string|null Canonical path, or null when it is not a file inside the tree.
835732 */
836- private function resolveWithinRoot ( string $ candidate ): ?string {
733+ private function resolveWithinRoot ( string $ candidate , DependencyTracker $ tracker ): ?string {
734+ $ tracker ->trackFileDependency ( $ candidate );
735+
837736 if ( ! is_file ( $ candidate ) ) {
838737 return null ;
839738 }
0 commit comments