88import sys
99from collections import defaultdict
1010from contextlib import contextmanager
11+ from functools import lru_cache
1112from pathlib import Path
1213from tempfile import NamedTemporaryFile
1314from typing import TYPE_CHECKING , Any , cast
@@ -149,49 +150,58 @@ def expand_paths_vars(paths: list[str]) -> list[str]:
149150 return paths
150151
151152
152- def kind_from_path (path : Path , * , base : bool = False ) -> FileType :
153- """Determine the file kind based on its name.
153+ @lru_cache (maxsize = 128 )
154+ def _get_project_root_for_dir (dir_path : str ) -> Path :
155+ """Cache project-root discovery per directory to avoid repeated FS walks."""
156+ project_root , _ = find_project_root ([dir_path ])
157+ return project_root
154158
155- When called with base=True, it will return the base file type instead
156- of the explicit one. That is expected to return 'yaml' for any yaml files.
157- """
159+
160+ def _resolve_kind_glob_path ( path : Path ) -> wcmatch . pathlib . PurePath :
161+ """Return a PurePath usable for glob matching against kind patterns."""
158162 # We attempt to use a relative path to the project root for glob matching.
159163 # This prevents parent directory names (like 'tasks') from triggering
160164 # false positives in kind discovery. See #4763.
161165 try :
162- project_root , _ = find_project_root ([str (path )])
166+ # Project root is shared by siblings; cache by directory to cut FS walks.
167+ lookup_path = path if path .is_dir () else path .parent
168+ project_root = _get_project_root_for_dir (str (lookup_path .resolve ()))
163169 # .resolve() ensures we handle symlinks and double-dots correctly
164170 rel_path = path .resolve ().relative_to (project_root .resolve ())
165- pathex = wcmatch .pathlib .PurePath (str (rel_path ))
171+ return wcmatch .pathlib .PurePath (str (rel_path ))
166172 except (ValueError , RuntimeError ):
167173 # Fallback to absolute if the file is outside the project root or can't be found
168- pathex = wcmatch .pathlib .PurePath (str (path .absolute ().resolve ()))
169- kinds = options .kinds if not base else BASE_KINDS
174+ return wcmatch .pathlib .PurePath (str (path .absolute ().resolve ()))
175+
176+
177+ def _match_kind_from_globs (
178+ pathex : wcmatch .pathlib .PurePath ,
179+ kinds : list [dict [str , str ]],
180+ path : Path ,
181+ ) -> FileType | None :
182+ """Return the first kind whose glob pattern matches path, if any."""
170183 for entry in kinds :
171184 for k , v in entry .items ():
172- if pathex .globmatch (
185+ if not pathex .globmatch (
173186 v ,
174187 flags = (
175188 wcmatch .pathlib .GLOBSTAR
176189 | wcmatch .pathlib .BRACE
177190 | wcmatch .pathlib .DOTGLOB
178191 ),
179192 ):
180- matched_kind = str (k )
181- # Namespace folders under roles/ can match **/roles/*/ without
182- # being role roots themselves. See #5079.
183- if (
184- matched_kind == "role"
185- and path .is_dir ()
186- and not _has_role_subdirs (path )
187- ):
188- continue
189- return matched_kind # type: ignore[return-value]
193+ continue
194+ matched_kind = str (k )
195+ # Namespace folders under roles/ can match **/roles/*/ without
196+ # being role roots themselves. See #5079.
197+ if matched_kind == "role" and path .is_dir () and not _has_role_subdirs (path ):
198+ continue
199+ return matched_kind # type: ignore[return-value]
200+ return None
190201
191- if base :
192- # Unknown base file type is default
193- return ""
194202
203+ def _fallback_kind_from_path (path : Path ) -> FileType :
204+ """Determine a fallback kind for a path not matched by any glob pattern."""
195205 if path .is_dir () and _has_role_subdirs (path ):
196206 return "role"
197207
@@ -213,6 +223,25 @@ def kind_from_path(path: Path, *, base: bool = False) -> FileType:
213223 return ""
214224
215225
226+ def kind_from_path (path : Path , * , base : bool = False ) -> FileType :
227+ """Determine the file kind based on its name.
228+
229+ When called with base=True, it will return the base file type instead
230+ of the explicit one. That is expected to return 'yaml' for any yaml files.
231+ """
232+ pathex = _resolve_kind_glob_path (path )
233+ kinds = options .kinds if not base else BASE_KINDS
234+ matched_kind = _match_kind_from_globs (pathex , kinds , path )
235+ if matched_kind is not None :
236+ return matched_kind
237+
238+ if base :
239+ # Unknown base file type is default
240+ return ""
241+
242+ return _fallback_kind_from_path (path )
243+
244+
216245# pylint: disable=too-many-instance-attributes
217246class Lintable :
218247 """Defines a file/folder that can be linted.
0 commit comments