Files
HubAudio/tools/docs/architecture_linter.py
T

529 lines
11 KiB
Python

#!/usr/bin/env python3
"""
HubAudio Architecture Linter
Checks that architecture documentation uses architectural roles
instead of hardware implementation names.
Usage
python architecture_linter.py docs --check
python architecture_linter.py docs --fix
python architecture_linter.py docs --report report.md
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from dataclasses import dataclass
from typing import Dict, List
import yaml
# ==========================================================
# Configuration
# ==========================================================
TARGET_DIRS = {
"adr",
"architecture",
"engineering",
}
SKIP_FILE_PATTERNS = (
"-Hardware-Architecture.md",
"-Hardware.md",
"-Datasheet.md",
)
CODE_BLOCK = re.compile(
r"```.*?```",
flags=re.DOTALL
)
INLINE_CODE = re.compile(
r"`[^`]+`"
)
ASCII_DIAGRAM = re.compile(
r"^[\s\|\+\-\>\<]+$"
)
# ==========================================================
# Data model
# ==========================================================
@dataclass
class Role:
role: str
implementation: str
aliases: List[str]
@dataclass
class Finding:
line: int
implementation: str
role: str
# ==========================================================
# Role database
# ==========================================================
class RoleDatabase:
def __init__(self, filename: Path):
self.roles: List[Role] = []
self.load(filename)
def load(self, filename: Path):
if not filename.exists():
raise FileNotFoundError(filename)
data = yaml.safe_load(
filename.read_text(encoding="utf8")
)
for role_name, cfg in data["roles"].items():
self.roles.append(
Role(
role=role_name,
implementation=cfg["implementation"],
# aliases=cfg.get("aliases", [])
aliases=list(
set(cfg.get("aliases", []))
-
{cfg["implementation"]}
)
)
)
# ==========================================================
# Markdown parser
# ==========================================================
class MarkdownCleaner:
"""
Temporarily removes code blocks before scanning.
"""
def __init__(self):
self.blocks = []
def protect(self, text: str):
def repl(match):
token = f"@@BLOCK{len(self.blocks)}@@"
self.blocks.append(match.group(0))
return token
text = CODE_BLOCK.sub(repl, text)
text = INLINE_CODE.sub(repl, text)
return text
def restore(self, text: str):
for i, block in enumerate(self.blocks):
text = text.replace(
f"@@BLOCK{i}@@",
block
)
return text
# ==========================================================
# Linter
# ==========================================================
class ArchitectureLinter:
def __init__(
self,
docs_root: Path,
roles: RoleDatabase
):
self.root = docs_root
self.roles = roles
self.findings: Dict[Path, List[Finding]] = {}
self.checked = 0
self.modified = 0
# ------------------------------------------------------
def iter_files(self):
for md in self.root.rglob("*.md"):
rel = md.relative_to(self.root)
if len(rel.parts) == 0:
continue
first = rel.parts[0]
if first not in TARGET_DIRS:
continue
skip = False
for pattern in SKIP_FILE_PATTERNS:
if md.name.endswith(pattern):
skip = True
break
if skip:
continue
yield md
# ------------------------------------------------------
def analyse_file(
self,
filename: Path
):
cleaner = MarkdownCleaner()
original = filename.read_text(
encoding="utf8"
)
text = cleaner.protect(original)
findings = []
lines = text.splitlines()
for lineno, line in enumerate(lines, start=1):
#
# Ignore implementation section
#
if ASCII_DIAGRAM.match(line):
continue
if "Current implementation" in line:
continue
if line.lstrip().startswith("#"):
continue
#
# Ignore titles like
#
# ADAU1467 Hardware Architecture
#
if line.startswith("#") and "Hardware" in line:
continue
for role in self.roles.roles:
names = [
role.implementation,
*role.aliases
]
for implementation in names:
pattern = (
r"\b"
+ re.escape(implementation)
+ r"\b"
)
if re.search(pattern, line):
findings.append(
Finding(
line=lineno,
implementation=implementation,
role=role.role
)
)
self.findings[filename] = findings
self.checked += 1
# ------------------------------------------------------
def check(self):
for file in self.iter_files():
self.analyse_file(file)
# ------------------------------------------------------
def fix(self):
for filename in self.iter_files():
cleaner = MarkdownCleaner()
original = filename.read_text(
encoding="utf8"
)
protected = cleaner.protect(original)
text = protected
changed = False
#
# sostituzioni
#
for role in self.roles.roles:
names = [
role.implementation,
*role.aliases
]
for implementation in names:
pattern = (
r"\b"
+ re.escape(implementation)
+ r"\b"
)
new_text = re.sub(
pattern,
role.role,
text
)
if new_text != text:
changed = True
text = new_text
text = cleaner.restore(text)
if changed:
filename.write_text(
text,
encoding="utf8"
)
self.modified += 1
# ------------------------------------------------------
def print_report(self):
issues = 0
print()
print("=" * 70)
print("HubAudio Architecture Linter")
print("=" * 70)
for filename in sorted(self.findings):
findings = self.findings[filename]
if not findings:
continue
issues += len(findings)
rel = filename.relative_to(self.root)
print()
print(rel)
print("-" * len(str(rel)))
for f in findings:
print(
f"line {f.line:4d} : "
f"{f.implementation}"
f" -> "
f"{f.role}"
)
print()
print("=" * 70)
print(f"Files checked : {self.checked}")
print(f"Issues found : {issues}")
print(f"Files changed : {self.modified}")
print("=" * 70)
# ------------------------------------------------------
def save_report(
self,
filename: Path
):
issues = 0
with filename.open(
"w",
encoding="utf8"
) as fp:
fp.write("# HubAudio Architecture Report\n\n")
for md in sorted(self.findings):
findings = self.findings[md]
if not findings:
continue
rel = md.relative_to(self.root)
fp.write(f"## {rel}\n\n")
for f in findings:
issues += 1
fp.write(
f"- Line {f.line}: "
f"`{f.implementation}` "
f"→ "
f"`{f.role}`\n"
)
fp.write("\n")
fp.write("---\n\n")
fp.write(f"Files checked: {self.checked}\n\n")
fp.write(f"Issues found: {issues}\n")
# ==========================================================
# CLI
# ==========================================================
def main():
parser = argparse.ArgumentParser(
description="HubAudio Architecture Linter"
)
parser.add_argument(
"docs",
help="Documentation root"
)
parser.add_argument(
"--roles",
default="tools/docs/roles.yml",
help="roles.yml"
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--check",
action="store_true"
)
parser.add_argument(
"--report",
help="Write markdown report"
)
parser.add_argument(
"--fix",
action="store_true",
help="Apply architecture naming fixes"
)
args = parser.parse_args()
docs = Path(args.docs)
if not docs.exists():
print("Documentation directory not found.")
sys.exit(1)
role_db = RoleDatabase(
Path(args.roles)
)
linter = ArchitectureLinter(
docs,
role_db
)
linter.check()
if args.fix:
print()
print("=" * 70)
print("Applying architecture fixes")
print("=" * 70)
linter.fix()
#
# Ricontrolla dopo la correzione
#
linter.findings.clear()
linter.checked = 0
linter.check()
linter.print_report()
if args.report:
linter.save_report(
Path(args.report)
)
print()
print("Report written to")
print(args.report)
if __name__ == "__main__":
main()