irminsul 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. irminsul/__init__.py +7 -0
  2. irminsul/checks/__init__.py +31 -0
  3. irminsul/checks/base.py +61 -0
  4. irminsul/checks/frontmatter.py +66 -0
  5. irminsul/checks/globs.py +76 -0
  6. irminsul/checks/links.py +100 -0
  7. irminsul/checks/schema_leak.py +98 -0
  8. irminsul/checks/uniqueness.py +146 -0
  9. irminsul/cli.py +230 -0
  10. irminsul/config.py +124 -0
  11. irminsul/docgraph.py +92 -0
  12. irminsul/frontmatter.py +123 -0
  13. irminsul/init/__init__.py +0 -0
  14. irminsul/init/command.py +149 -0
  15. irminsul/init/detector.py +65 -0
  16. irminsul/init/scaffolds/docs/00-foundation/principles.md.j2 +28 -0
  17. irminsul/init/scaffolds/docs/10-architecture/overview.md.j2 +26 -0
  18. irminsul/init/scaffolds/docs/20-components/INDEX.md.j2 +16 -0
  19. irminsul/init/scaffolds/docs/30-workflows/INDEX.md.j2 +14 -0
  20. irminsul/init/scaffolds/docs/40-reference/INDEX.md.j2 +14 -0
  21. irminsul/init/scaffolds/docs/50-decisions/0001-adopt-irminsul.md.j2 +35 -0
  22. irminsul/init/scaffolds/docs/50-decisions/INDEX.md.j2 +14 -0
  23. irminsul/init/scaffolds/docs/60-operations/INDEX.md.j2 +14 -0
  24. irminsul/init/scaffolds/docs/70-knowledge/INDEX.md.j2 +14 -0
  25. irminsul/init/scaffolds/docs/80-evolution/INDEX.md.j2 +14 -0
  26. irminsul/init/scaffolds/docs/90-meta/INDEX.md.j2 +14 -0
  27. irminsul/init/scaffolds/docs/CONTRIBUTING.md.j2 +12 -0
  28. irminsul/init/scaffolds/docs/GLOSSARY.md.j2 +13 -0
  29. irminsul/init/scaffolds/docs/README.md.j2 +18 -0
  30. irminsul/init/scaffolds/irminsul.toml.j2 +12 -0
  31. irminsul/init/workflows/docs-nightly.yml.j2 +23 -0
  32. irminsul/init/workflows/docs-pr.yml.j2 +20 -0
  33. irminsul/languages/__init__.py +23 -0
  34. irminsul/languages/base.py +20 -0
  35. irminsul/languages/python.py +25 -0
  36. irminsul/languages/typescript.py +21 -0
  37. irminsul/render/__init__.py +8 -0
  38. irminsul/render/base.py +15 -0
  39. irminsul/render/mkdocs.py +127 -0
  40. irminsul-0.1.0.dev0.dist-info/METADATA +96 -0
  41. irminsul-0.1.0.dev0.dist-info/RECORD +44 -0
  42. irminsul-0.1.0.dev0.dist-info/WHEEL +4 -0
  43. irminsul-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  44. irminsul-0.1.0.dev0.dist-info/licenses/LICENSE +661 -0
irminsul/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Irminsul — a documentation system for complex codebases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0.dev0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,31 @@
1
+ """Check registry.
2
+
3
+ Looking-up by name keeps the CLI/config side honest: `irminsul.toml`'s
4
+ `checks.hard` field is just a list of strings, and we resolve them here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from irminsul.checks.base import Check, Finding, Severity, sort_findings, summarize
10
+ from irminsul.checks.frontmatter import FrontmatterCheck
11
+ from irminsul.checks.globs import GlobsCheck
12
+ from irminsul.checks.links import LinksCheck
13
+ from irminsul.checks.schema_leak import SchemaLeakCheck
14
+ from irminsul.checks.uniqueness import UniquenessCheck
15
+
16
+ HARD_REGISTRY: dict[str, type[Check]] = {
17
+ FrontmatterCheck.name: FrontmatterCheck,
18
+ GlobsCheck.name: GlobsCheck,
19
+ UniquenessCheck.name: UniquenessCheck,
20
+ LinksCheck.name: LinksCheck,
21
+ SchemaLeakCheck.name: SchemaLeakCheck,
22
+ }
23
+
24
+ __all__ = [
25
+ "HARD_REGISTRY",
26
+ "Check",
27
+ "Finding",
28
+ "Severity",
29
+ "sort_findings",
30
+ "summarize",
31
+ ]
@@ -0,0 +1,61 @@
1
+ """Check protocol, finding model, and reporting helpers.
2
+
3
+ Every check ingests a `DocGraph` and returns a list of `Finding`s. The CLI
4
+ prints them and uses their severity to decide the exit code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+ from typing import ClassVar, Protocol, runtime_checkable
13
+
14
+ from irminsul.docgraph import DocGraph
15
+
16
+
17
+ class Severity(StrEnum):
18
+ error = "error"
19
+ warning = "warning"
20
+ info = "info"
21
+
22
+
23
+ _SEVERITY_ORDER = {Severity.error: 0, Severity.warning: 1, Severity.info: 2}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Finding:
28
+ check: str
29
+ severity: Severity
30
+ message: str
31
+ path: Path | None = None
32
+ doc_id: str | None = None
33
+ line: int | None = None
34
+
35
+
36
+ @runtime_checkable
37
+ class Check(Protocol):
38
+ name: ClassVar[str]
39
+ default_severity: ClassVar[Severity]
40
+
41
+ def run(self, graph: DocGraph) -> list[Finding]: ...
42
+
43
+
44
+ def sort_findings(findings: list[Finding]) -> list[Finding]:
45
+ """Errors first, then warnings, then info; stable on (path, line, check)."""
46
+ return sorted(
47
+ findings,
48
+ key=lambda f: (
49
+ _SEVERITY_ORDER[f.severity],
50
+ str(f.path) if f.path else "",
51
+ f.line if f.line is not None else -1,
52
+ f.check,
53
+ ),
54
+ )
55
+
56
+
57
+ def summarize(findings: list[Finding]) -> dict[Severity, int]:
58
+ counts = {s: 0 for s in Severity}
59
+ for f in findings:
60
+ counts[f.severity] += 1
61
+ return counts
@@ -0,0 +1,66 @@
1
+ """FrontmatterCheck — required fields, enums, ID/filename agreement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from irminsul.checks.base import Finding, Severity
8
+ from irminsul.docgraph import DocGraph
9
+ from irminsul.frontmatter import expected_id_for
10
+
11
+
12
+ class FrontmatterCheck:
13
+ name: ClassVar[str] = "frontmatter"
14
+ default_severity: ClassVar[Severity] = Severity.error
15
+
16
+ def run(self, graph: DocGraph) -> list[Finding]:
17
+ out: list[Finding] = []
18
+
19
+ for failure in graph.parse_failures:
20
+ out.append(
21
+ Finding(
22
+ check=self.name,
23
+ severity=Severity.error,
24
+ message=f"frontmatter parse error: {failure.error}",
25
+ path=failure.path,
26
+ )
27
+ )
28
+
29
+ for path in graph.missing_frontmatter:
30
+ out.append(
31
+ Finding(
32
+ check=self.name,
33
+ severity=Severity.error,
34
+ message="missing frontmatter (required for every doc atom)",
35
+ path=path,
36
+ )
37
+ )
38
+
39
+ for node in graph.nodes.values():
40
+ expected = expected_id_for(node.path)
41
+ if node.frontmatter.id != expected:
42
+ out.append(
43
+ Finding(
44
+ check=self.name,
45
+ severity=Severity.error,
46
+ message=(
47
+ f"id '{node.frontmatter.id}' does not match filename "
48
+ f"(expected '{expected}')"
49
+ ),
50
+ path=node.path,
51
+ doc_id=node.frontmatter.id,
52
+ )
53
+ )
54
+
55
+ for dup_id, first_path, conflicting_path in graph.duplicate_ids:
56
+ out.append(
57
+ Finding(
58
+ check=self.name,
59
+ severity=Severity.error,
60
+ message=(f"duplicate id '{dup_id}' (also defined at {first_path})"),
61
+ path=conflicting_path,
62
+ doc_id=dup_id,
63
+ )
64
+ )
65
+
66
+ return out
@@ -0,0 +1,76 @@
1
+ """GlobsCheck — every `describes` pattern must resolve to ≥1 source file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath
6
+ from typing import ClassVar
7
+
8
+ from pathspec import GitIgnoreSpec
9
+
10
+ from irminsul.checks.base import Finding, Severity
11
+ from irminsul.docgraph import DocGraph
12
+
13
+
14
+ def walk_source_files(repo_root: Path, source_roots: list[str]) -> tuple[list[str], list[str]]:
15
+ """Return (relative_posix_paths, missing_roots).
16
+
17
+ Walks every file under each existing source root. Skips dot-directories
18
+ (`.git`, `.venv`, `.mypy_cache`, ...) so we don't drag environment cruft
19
+ into the source set. A missing root isn't fatal here — surfaced separately
20
+ so the caller can warn rather than error.
21
+ """
22
+ files: list[str] = []
23
+ missing: list[str] = []
24
+ for root in source_roots:
25
+ abs_root = (repo_root / root).resolve()
26
+ if not abs_root.exists():
27
+ missing.append(root)
28
+ continue
29
+ for path in abs_root.rglob("*"):
30
+ if not path.is_file():
31
+ continue
32
+ if any(part.startswith(".") for part in path.relative_to(repo_root).parts):
33
+ continue
34
+ rel = PurePosixPath(*path.relative_to(repo_root).parts)
35
+ files.append(str(rel))
36
+ return files, missing
37
+
38
+
39
+ class GlobsCheck:
40
+ name: ClassVar[str] = "globs"
41
+ default_severity: ClassVar[Severity] = Severity.error
42
+
43
+ def run(self, graph: DocGraph) -> list[Finding]:
44
+ if graph.config is None or graph.repo_root is None:
45
+ return []
46
+
47
+ source_files, missing_roots = walk_source_files(
48
+ graph.repo_root, graph.config.paths.source_roots
49
+ )
50
+
51
+ out: list[Finding] = []
52
+
53
+ for root in missing_roots:
54
+ out.append(
55
+ Finding(
56
+ check=self.name,
57
+ severity=Severity.warning,
58
+ message=f"source root '{root}' does not exist",
59
+ )
60
+ )
61
+
62
+ for node in graph.nodes.values():
63
+ for pattern in node.frontmatter.describes:
64
+ spec = GitIgnoreSpec.from_lines([pattern])
65
+ if not any(spec.match_file(f) for f in source_files):
66
+ out.append(
67
+ Finding(
68
+ check=self.name,
69
+ severity=Severity.error,
70
+ message=(f"describes pattern '{pattern}' matched zero files"),
71
+ path=node.path,
72
+ doc_id=node.id,
73
+ )
74
+ )
75
+
76
+ return out
@@ -0,0 +1,100 @@
1
+ """LinksCheck — internal markdown link integrity.
2
+
3
+ Walks each doc body via markdown-it and verifies every relative link target
4
+ resolves to an existing file inside the repo. External URLs (`http`/`https`/
5
+ `mailto`/`tel`) and same-doc anchor links (`#heading`) are skipped — same-doc
6
+ anchor validation and external link checking land in Sprint 2.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import PurePosixPath
12
+ from typing import ClassVar
13
+ from urllib.parse import urlparse
14
+
15
+ from markdown_it import MarkdownIt
16
+
17
+ from irminsul.checks.base import Finding, Severity
18
+ from irminsul.docgraph import DocGraph, DocNode
19
+
20
+ _SKIP_SCHEMES = {"http", "https", "mailto", "tel", "ftp", "ftps", "data"}
21
+
22
+
23
+ def _is_external_or_anchor(href: str) -> bool:
24
+ if not href:
25
+ return True
26
+ if href.startswith("#"):
27
+ return True
28
+ parsed = urlparse(href)
29
+ return parsed.scheme.lower() in _SKIP_SCHEMES
30
+
31
+
32
+ def _extract_link_hrefs(body: str, md: MarkdownIt) -> list[str]:
33
+ """Return every href attribute on a link_open token in the doc body."""
34
+ hrefs: list[str] = []
35
+ tokens = md.parse(body)
36
+ for token in tokens:
37
+ if token.type == "inline" and token.children:
38
+ for child in token.children:
39
+ if child.type == "link_open":
40
+ href = child.attrGet("href")
41
+ if isinstance(href, str):
42
+ hrefs.append(href)
43
+ return hrefs
44
+
45
+
46
+ def _resolve_target(doc: DocNode, href: str, repo_root: PurePosixPath) -> PurePosixPath:
47
+ """Resolve a relative link's target to a repo-relative POSIX path.
48
+
49
+ Strips the trailing `#anchor`, treats the href as relative to the doc's
50
+ parent directory, and joins onto `repo_root` to canonicalize.
51
+ """
52
+ target = href.split("#", 1)[0]
53
+ doc_parent = PurePosixPath(doc.path.as_posix()).parent
54
+ # Posix-normalize by going through parts; PurePosixPath handles "../".
55
+ raw = doc_parent / target
56
+ # PurePosixPath has no resolve(); collapse .. manually.
57
+ parts: list[str] = []
58
+ for part in raw.parts:
59
+ if part == "..":
60
+ if parts:
61
+ parts.pop()
62
+ elif part != ".":
63
+ parts.append(part)
64
+ return PurePosixPath(*parts)
65
+
66
+
67
+ class LinksCheck:
68
+ name: ClassVar[str] = "links"
69
+ default_severity: ClassVar[Severity] = Severity.error
70
+
71
+ def __init__(self) -> None:
72
+ self._md = MarkdownIt("commonmark")
73
+
74
+ def run(self, graph: DocGraph) -> list[Finding]:
75
+ if graph.repo_root is None:
76
+ return []
77
+
78
+ repo_root_posix = PurePosixPath(*graph.repo_root.resolve().parts)
79
+ out: list[Finding] = []
80
+
81
+ for node in graph.nodes.values():
82
+ for href in _extract_link_hrefs(node.body, self._md):
83
+ if _is_external_or_anchor(href):
84
+ continue
85
+
86
+ target_rel = _resolve_target(node, href, repo_root_posix)
87
+ target_abs = graph.repo_root / target_rel
88
+
89
+ if not target_abs.exists():
90
+ out.append(
91
+ Finding(
92
+ check=self.name,
93
+ severity=Severity.error,
94
+ message=f"broken link: '{href}' (resolved to '{target_rel}')",
95
+ path=node.path,
96
+ doc_id=node.id,
97
+ )
98
+ )
99
+
100
+ return out
@@ -0,0 +1,98 @@
1
+ """SchemaLeakCheck — type/schema definitions don't belong in narrative docs.
2
+
3
+ Scans every doc inside the protected glob (`docs/20-components/**/*.md` for
4
+ v0.1.0; configurable later) line-by-line against the active language profiles'
5
+ patterns. Tracks fenced-code-block state so a snippet labelled ```toml or
6
+ ```text doesn't trigger findings just because it contains the word "class".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Sequence
13
+ from typing import ClassVar
14
+
15
+ from pathspec import GitIgnoreSpec
16
+
17
+ from irminsul.checks.base import Finding, Severity
18
+ from irminsul.docgraph import DocGraph
19
+ from irminsul.languages import LANGUAGE_REGISTRY, LanguageProfile
20
+
21
+ # Hardcoded for v0.1.0; expose via config in Sprint 2.
22
+ _PROTECTED_GLOB = GitIgnoreSpec.from_lines(["docs/20-components/**/*.md"])
23
+
24
+ # Languages whose code-blocks should be scanned for schema leaks. A markdown
25
+ # fence labelled with one of these names is treated as scannable; anything else
26
+ # (toml, json, text, yaml, …) is exempt.
27
+ _SCANNABLE_FENCE_LANGS = {"", "python", "py", "sql", "ts", "typescript", "tsx", "javascript", "js"}
28
+
29
+ _FENCE_RE = re.compile(r"^\s*```(\S*)\s*$")
30
+
31
+
32
+ def _truncate_pattern(pattern: re.Pattern[str], limit: int = 60) -> str:
33
+ src = pattern.pattern
34
+ return src if len(src) <= limit else src[: limit - 1] + "…"
35
+
36
+
37
+ def _active_profiles(enabled: Sequence[str]) -> list[LanguageProfile]:
38
+ return [LANGUAGE_REGISTRY[name] for name in enabled if name in LANGUAGE_REGISTRY]
39
+
40
+
41
+ class SchemaLeakCheck:
42
+ name: ClassVar[str] = "schema-leak"
43
+ default_severity: ClassVar[Severity] = Severity.error
44
+
45
+ def run(self, graph: DocGraph) -> list[Finding]:
46
+ if graph.config is None:
47
+ return []
48
+
49
+ profiles = _active_profiles(graph.config.languages.enabled)
50
+ if not profiles:
51
+ return []
52
+
53
+ out: list[Finding] = []
54
+
55
+ for node in graph.nodes.values():
56
+ if not _PROTECTED_GLOB.match_file(node.path.as_posix()):
57
+ continue
58
+
59
+ in_fence = False
60
+ fence_scannable = True
61
+ for lineno, line in enumerate(node.body.splitlines(), start=1):
62
+ fence_match = _FENCE_RE.match(line)
63
+ if fence_match:
64
+ if in_fence:
65
+ in_fence = False
66
+ fence_scannable = True
67
+ else:
68
+ in_fence = True
69
+ lang = fence_match.group(1).lower()
70
+ fence_scannable = lang in _SCANNABLE_FENCE_LANGS
71
+ continue
72
+
73
+ if in_fence and not fence_scannable:
74
+ continue
75
+
76
+ for profile in profiles:
77
+ for pattern in profile.schema_leak_patterns:
78
+ if pattern.search(line):
79
+ out.append(
80
+ Finding(
81
+ check=self.name,
82
+ severity=Severity.error,
83
+ message=(
84
+ f"schema-leak: line matches "
85
+ f"{profile.name}:/{_truncate_pattern(pattern)}/ -- "
86
+ "type/schema definitions belong in 40-reference/"
87
+ ),
88
+ path=node.path,
89
+ doc_id=node.id,
90
+ line=lineno,
91
+ )
92
+ )
93
+ break # one finding per (line, profile)
94
+ else:
95
+ continue
96
+ break
97
+
98
+ return out
@@ -0,0 +1,146 @@
1
+ """UniquenessCheck — every source file is claimed by exactly one most-specific doc.
2
+
3
+ Implements Part VI ("Hierarchical Describes") of `Irminsul-reference.md`. Two
4
+ docs claiming the same files at the same specificity is an error (silent
5
+ duplication). A source file in a covered directory that no doc claims is a
6
+ warning (silent omission). A child doc claiming a narrower glob wins over a
7
+ parent's broader claim — that's the most-specific-match rule, and it's what
8
+ lets `planner/INDEX.md` say `describes: [app/planner/**]` while
9
+ `planner/routing.md` adds `describes: [app/planner/routing/*.py]` without
10
+ either docking the other.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import defaultdict
16
+ from collections.abc import Iterable
17
+ from pathlib import Path
18
+ from typing import ClassVar
19
+
20
+ from pathspec import GitIgnoreSpec
21
+
22
+ from irminsul.checks.base import Finding, Severity
23
+ from irminsul.checks.globs import walk_source_files
24
+ from irminsul.docgraph import DocGraph, DocNode
25
+
26
+ # Files that are usually noise from a doc-coverage standpoint. Keeping the
27
+ # omission warning quiet here trades a tiny risk of missing a legit doc gap
28
+ # against the much larger annoyance of warning on every `__init__.py`.
29
+ _OMISSION_SKIP = GitIgnoreSpec.from_lines(
30
+ [
31
+ "__init__.py",
32
+ "__main__.py",
33
+ "conftest.py",
34
+ "*.pyi",
35
+ "test_*.py",
36
+ "*_test.py",
37
+ "*.test.ts",
38
+ "*.spec.ts",
39
+ "*.test.tsx",
40
+ "*.spec.tsx",
41
+ "*.d.ts",
42
+ ]
43
+ )
44
+
45
+
46
+ def specificity(pattern: str) -> tuple[int, int, int]:
47
+ """Higher tuple = more specific. Compared lexicographically.
48
+
49
+ Components:
50
+ - count of literal (no-wildcard) path segments — exact paths beat globs
51
+ - total path depth — deeper claims beat shallower
52
+ - negated wildcard-character count — fewer wildcards beats more
53
+ """
54
+ parts = pattern.split("/")
55
+ literal_segments = sum(1 for p in parts if "*" not in p and "?" not in p)
56
+ depth = len(parts)
57
+ wildcard_chars = sum(p.count("*") + p.count("?") for p in parts)
58
+ return (literal_segments, depth, -wildcard_chars)
59
+
60
+
61
+ class UniquenessCheck:
62
+ name: ClassVar[str] = "uniqueness"
63
+ default_severity: ClassVar[Severity] = Severity.error
64
+
65
+ def run(self, graph: DocGraph) -> list[Finding]:
66
+ if graph.config is None or graph.repo_root is None:
67
+ return []
68
+
69
+ source_files, _missing = walk_source_files(graph.repo_root, graph.config.paths.source_roots)
70
+
71
+ # claims_by_file: source file -> list of (DocNode, pattern, score).
72
+ claims_by_file: dict[str, list[tuple[DocNode, str, tuple[int, int, int]]]] = defaultdict(
73
+ list
74
+ )
75
+
76
+ for node in graph.nodes.values():
77
+ for pattern in node.frontmatter.describes:
78
+ spec = GitIgnoreSpec.from_lines([pattern])
79
+ score = specificity(pattern)
80
+ for source_file in source_files:
81
+ if spec.match_file(source_file):
82
+ claims_by_file[source_file].append((node, pattern, score))
83
+
84
+ out: list[Finding] = []
85
+
86
+ # Tie at most-specific level → silent duplication, error.
87
+ for source_file, claims in claims_by_file.items():
88
+ top_score = max(c[2] for c in claims)
89
+ top_claims = [c for c in claims if c[2] == top_score]
90
+ if len(top_claims) > 1:
91
+ claim_descs = ", ".join(
92
+ f"{node.path.as_posix()} (`{pattern}`)" for node, pattern, _ in top_claims
93
+ )
94
+ out.append(
95
+ Finding(
96
+ check=self.name,
97
+ severity=Severity.error,
98
+ message=(
99
+ f"source file '{source_file}' is claimed at the same "
100
+ f"specificity by: {claim_descs}"
101
+ ),
102
+ )
103
+ )
104
+
105
+ # Covered directories: parents of every claimed file.
106
+ covered_dirs = _covered_dirs(claims_by_file.keys())
107
+
108
+ # Omission: a file under a covered directory that no doc claims.
109
+ for source_file in source_files:
110
+ if source_file in claims_by_file:
111
+ continue
112
+ if not _is_in_covered_dir(source_file, covered_dirs):
113
+ continue
114
+ if _OMISSION_SKIP.match_file(source_file):
115
+ continue
116
+ out.append(
117
+ Finding(
118
+ check=self.name,
119
+ severity=Severity.warning,
120
+ message=(
121
+ f"source file '{source_file}' lives in a doc-covered "
122
+ "directory but no doc claims it"
123
+ ),
124
+ )
125
+ )
126
+
127
+ return out
128
+
129
+
130
+ def _covered_dirs(claimed_files: Iterable[str]) -> set[str]:
131
+ """Every directory that has at least one claimed file under it (at any
132
+ depth)."""
133
+ dirs: set[str] = set()
134
+ for f in claimed_files:
135
+ parent = str(Path(f).parent.as_posix())
136
+ while parent and parent != ".":
137
+ dirs.add(parent)
138
+ parent = str(Path(parent).parent.as_posix())
139
+ if parent == ".":
140
+ break
141
+ return dirs
142
+
143
+
144
+ def _is_in_covered_dir(source_file: str, covered: set[str]) -> bool:
145
+ parent = str(Path(source_file).parent.as_posix())
146
+ return parent in covered