529 lines
11 KiB
Python
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() |