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.
- irminsul/__init__.py +7 -0
- irminsul/checks/__init__.py +31 -0
- irminsul/checks/base.py +61 -0
- irminsul/checks/frontmatter.py +66 -0
- irminsul/checks/globs.py +76 -0
- irminsul/checks/links.py +100 -0
- irminsul/checks/schema_leak.py +98 -0
- irminsul/checks/uniqueness.py +146 -0
- irminsul/cli.py +230 -0
- irminsul/config.py +124 -0
- irminsul/docgraph.py +92 -0
- irminsul/frontmatter.py +123 -0
- irminsul/init/__init__.py +0 -0
- irminsul/init/command.py +149 -0
- irminsul/init/detector.py +65 -0
- irminsul/init/scaffolds/docs/00-foundation/principles.md.j2 +28 -0
- irminsul/init/scaffolds/docs/10-architecture/overview.md.j2 +26 -0
- irminsul/init/scaffolds/docs/20-components/INDEX.md.j2 +16 -0
- irminsul/init/scaffolds/docs/30-workflows/INDEX.md.j2 +14 -0
- irminsul/init/scaffolds/docs/40-reference/INDEX.md.j2 +14 -0
- irminsul/init/scaffolds/docs/50-decisions/0001-adopt-irminsul.md.j2 +35 -0
- irminsul/init/scaffolds/docs/50-decisions/INDEX.md.j2 +14 -0
- irminsul/init/scaffolds/docs/60-operations/INDEX.md.j2 +14 -0
- irminsul/init/scaffolds/docs/70-knowledge/INDEX.md.j2 +14 -0
- irminsul/init/scaffolds/docs/80-evolution/INDEX.md.j2 +14 -0
- irminsul/init/scaffolds/docs/90-meta/INDEX.md.j2 +14 -0
- irminsul/init/scaffolds/docs/CONTRIBUTING.md.j2 +12 -0
- irminsul/init/scaffolds/docs/GLOSSARY.md.j2 +13 -0
- irminsul/init/scaffolds/docs/README.md.j2 +18 -0
- irminsul/init/scaffolds/irminsul.toml.j2 +12 -0
- irminsul/init/workflows/docs-nightly.yml.j2 +23 -0
- irminsul/init/workflows/docs-pr.yml.j2 +20 -0
- irminsul/languages/__init__.py +23 -0
- irminsul/languages/base.py +20 -0
- irminsul/languages/python.py +25 -0
- irminsul/languages/typescript.py +21 -0
- irminsul/render/__init__.py +8 -0
- irminsul/render/base.py +15 -0
- irminsul/render/mkdocs.py +127 -0
- irminsul-0.1.0.dev0.dist-info/METADATA +96 -0
- irminsul-0.1.0.dev0.dist-info/RECORD +44 -0
- irminsul-0.1.0.dev0.dist-info/WHEEL +4 -0
- irminsul-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- irminsul-0.1.0.dev0.dist-info/licenses/LICENSE +661 -0
irminsul/__init__.py
ADDED
|
@@ -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
|
+
]
|
irminsul/checks/base.py
ADDED
|
@@ -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
|
irminsul/checks/globs.py
ADDED
|
@@ -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
|
irminsul/checks/links.py
ADDED
|
@@ -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
|