Refactor HubAudio architecture domains and add documentation tooling
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HubAudio Documentation Role Refactor
|
||||
|
||||
Replaces hardware implementation names with architectural role names.
|
||||
|
||||
Example:
|
||||
|
||||
python role_refactor.py docs --dry-run
|
||||
python role_refactor.py docs --write
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mapping
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
ROLE_MAP = {
|
||||
"ADAU1467": "Audio Processor",
|
||||
"ESP32-S3": "System Controller",
|
||||
"Si4684": "Radio Receiver",
|
||||
"BT1026": "Bluetooth Receiver",
|
||||
"BT1035": "Bluetooth Transmitter",
|
||||
"PCS2P2309NZ": "Clock Buffer",
|
||||
"BQ24074": "Battery Charger",
|
||||
"BQ27441": "Fuel Gauge",
|
||||
"TPS65217": "Power Manager",
|
||||
"25AA1024": "Audio EEPROM",
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Ignore folders
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
IGNORE = {
|
||||
"datasheets",
|
||||
"hardware",
|
||||
"firmware",
|
||||
".git",
|
||||
"__pycache__",
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Replace inside one file
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def process_file(path: Path, write: bool):
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
original = text
|
||||
|
||||
replacements = []
|
||||
|
||||
for implementation, role in ROLE_MAP.items():
|
||||
|
||||
pattern = r"\b" + re.escape(implementation) + r"\b"
|
||||
|
||||
matches = len(re.findall(pattern, text))
|
||||
|
||||
if matches:
|
||||
|
||||
text = re.sub(pattern, role, text)
|
||||
|
||||
replacements.append((implementation, role, matches))
|
||||
|
||||
if not replacements:
|
||||
return False, []
|
||||
|
||||
print(f"\n{path}")
|
||||
|
||||
total = 0
|
||||
|
||||
for old, new, count in replacements:
|
||||
|
||||
total += count
|
||||
|
||||
print(f" {old:18} -> {new:24} ({count})")
|
||||
|
||||
print(f" Total replacements: {total}")
|
||||
|
||||
if write:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
return True, replacements
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Scan tree
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def scan(root: Path, write: bool):
|
||||
|
||||
modified = 0
|
||||
|
||||
files = 0
|
||||
|
||||
for file in root.rglob("*.md"):
|
||||
|
||||
if any(part in IGNORE for part in file.parts):
|
||||
continue
|
||||
|
||||
changed, _ = process_file(file, write)
|
||||
|
||||
if changed:
|
||||
modified += 1
|
||||
|
||||
files += 1
|
||||
|
||||
print("\n----------------------------------------")
|
||||
|
||||
print(f"Markdown scanned : {files}")
|
||||
print(f"Files modified : {modified}")
|
||||
|
||||
if write:
|
||||
print("\nChanges written to disk.")
|
||||
else:
|
||||
print("\nDry run completed.")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Main
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
parser.add_argument(
|
||||
"path",
|
||||
help="Root documentation directory"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--write",
|
||||
action="store_true",
|
||||
help="Write modifications"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Only show modifications"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.write and args.dry_run:
|
||||
print("Choose either --write or --dry-run")
|
||||
sys.exit(1)
|
||||
|
||||
root = Path(args.path)
|
||||
|
||||
if not root.exists():
|
||||
print("Directory not found.")
|
||||
sys.exit(1)
|
||||
|
||||
scan(root, write=args.write)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
roles:
|
||||
|
||||
Audio Processor:
|
||||
implementation: ADAU1467
|
||||
category: functional
|
||||
aliases: []
|
||||
|
||||
System Controller:
|
||||
implementation: ESP32-S3
|
||||
category: functional
|
||||
aliases: []
|
||||
|
||||
Radio Receiver:
|
||||
implementation: Si4684
|
||||
category: functional
|
||||
aliases: []
|
||||
|
||||
Bluetooth Receiver:
|
||||
implementation: BT1026
|
||||
category: functional
|
||||
aliases: []
|
||||
|
||||
Bluetooth Transmitter:
|
||||
implementation: BT1035
|
||||
category: functional
|
||||
aliases: []
|
||||
|
||||
Clock Buffer:
|
||||
implementation: PCS2P2309NZ
|
||||
category: infrastructure
|
||||
aliases: []
|
||||
|
||||
Audio EEPROM:
|
||||
implementation: 25AA1024
|
||||
category: memory
|
||||
aliases: []
|
||||
|
||||
Battery Charger:
|
||||
implementation: BQ24074
|
||||
category: power
|
||||
aliases: []
|
||||
|
||||
Fuel Gauge:
|
||||
implementation: BQ27441
|
||||
category: power
|
||||
aliases: []
|
||||
|
||||
Power Manager:
|
||||
implementation: TPS65217
|
||||
category: power
|
||||
aliases: []
|
||||
Reference in New Issue
Block a user