This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
EXPECTED_PLATFORMIO_CORE = "6.1.18"
|
||||
EXPECTED_PLATFORM = "https://github.com/pioarduino/platform-espressif32/releases/download/51.03.07/platform-espressif32.zip"
|
||||
EXPECTED_ENVS = {"production", "debug", "test"}
|
||||
EXPECTED_BOARD = "ttgo-lora32-v1"
|
||||
EXPECTED_FRAMEWORK = "arduino"
|
||||
PROPOSED_EXACT_PINS = {
|
||||
"sandeepmistry/LoRa": "0.8.0",
|
||||
"bblanchon/ArduinoJson": "6.21.6",
|
||||
"adafruit/Adafruit SSD1306": "2.5.17",
|
||||
"adafruit/Adafruit GFX Library": "1.12.6",
|
||||
"knolleary/PubSubClient": "2.8.0",
|
||||
"throwtheswitch/Unity": "2.6.1",
|
||||
}
|
||||
|
||||
|
||||
def command_text(command: list[str]) -> str:
|
||||
return " ".join(f'"{part}"' if any(ch.isspace() for ch in part) else part for part in command)
|
||||
|
||||
|
||||
def run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
env["PYTHONUTF8"] = "1"
|
||||
env["PIO_FORCE_COLOR"] = "0"
|
||||
print(f"$ {command_text(command)}", flush=True)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
print(result.stdout, end="")
|
||||
return result
|
||||
|
||||
|
||||
def split_multiline(value: str) -> list[str]:
|
||||
return [line.strip() for line in value.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def parse_platformio() -> configparser.RawConfigParser:
|
||||
parser = configparser.RawConfigParser()
|
||||
parser.optionxform = str
|
||||
with (ROOT / "platformio.ini").open(encoding="utf-8") as handle:
|
||||
parser.read_file(handle)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
version_result = run([sys.executable, "-m", "platformio", "--version"])
|
||||
if version_result.returncode != 0:
|
||||
errors.append("PlatformIO version command failed.")
|
||||
else:
|
||||
match = re.search(r"version\s+([0-9.]+)", version_result.stdout)
|
||||
observed = match.group(1) if match else "unknown"
|
||||
if observed != EXPECTED_PLATFORMIO_CORE:
|
||||
errors.append(f"Expected PlatformIO Core {EXPECTED_PLATFORMIO_CORE}, observed {observed}.")
|
||||
|
||||
config_result = run([sys.executable, "-m", "platformio", "project", "config", "--json-output"])
|
||||
if config_result.returncode != 0:
|
||||
errors.append("PlatformIO project config command failed.")
|
||||
|
||||
pkg_result = run([sys.executable, "-m", "platformio", "pkg", "list"])
|
||||
if pkg_result.returncode != 0:
|
||||
errors.append("PlatformIO package resolution failed.")
|
||||
|
||||
parser = parse_platformio()
|
||||
env_sections = {section.split(":", 1)[1] for section in parser.sections() if section.startswith("env:")}
|
||||
missing = EXPECTED_ENVS - env_sections
|
||||
if missing:
|
||||
errors.append(f"Missing required PlatformIO environments: {', '.join(sorted(missing))}")
|
||||
|
||||
base_platform = parser.get("env", "platform", fallback="")
|
||||
if base_platform != EXPECTED_PLATFORM:
|
||||
errors.append("Base PlatformIO platform is not the expected pinned pioarduino release URL.")
|
||||
|
||||
base_board = parser.get("env", "board", fallback="")
|
||||
base_framework = parser.get("env", "framework", fallback="")
|
||||
if base_board != EXPECTED_BOARD:
|
||||
errors.append(f"Expected board {EXPECTED_BOARD}, observed {base_board}.")
|
||||
if base_framework != EXPECTED_FRAMEWORK:
|
||||
errors.append(f"Expected framework {EXPECTED_FRAMEWORK}, observed {base_framework}.")
|
||||
|
||||
lib_deps = split_multiline(parser.get("env", "lib_deps", fallback=""))
|
||||
for dep in lib_deps:
|
||||
if "@" not in dep:
|
||||
errors.append(f"Dependency lacks an explicit version requirement: {dep}")
|
||||
elif "@^" in dep:
|
||||
warnings.append(f"Ranged dependency allowed for now, exact pin proposed separately: {dep}")
|
||||
|
||||
for env_name in EXPECTED_ENVS:
|
||||
section = f"env:{env_name}"
|
||||
if not parser.has_section(section):
|
||||
continue
|
||||
if parser.get(section, "platform", fallback=base_platform) != EXPECTED_PLATFORM:
|
||||
errors.append(f"{section} platform differs from the pinned base platform.")
|
||||
if parser.get(section, "board", fallback=base_board) != EXPECTED_BOARD:
|
||||
errors.append(f"{section} board differs from {EXPECTED_BOARD}.")
|
||||
if parser.get(section, "framework", fallback=base_framework) != EXPECTED_FRAMEWORK:
|
||||
errors.append(f"{section} framework differs from Arduino.")
|
||||
|
||||
print("Proposed exact direct dependency pins for a separate commit:")
|
||||
for package, version in PROPOSED_EXACT_PINS.items():
|
||||
print(f"- {package}@{version}")
|
||||
|
||||
for warning in warnings:
|
||||
print(f"WARNING: {warning}")
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
REQUIRED_PATHS = [
|
||||
"AGENTS.md",
|
||||
".gitea/workflows/ci.yml",
|
||||
"README.md",
|
||||
"Requirements.md",
|
||||
"docs/TESTS.md",
|
||||
"docs/POWER_OPTIMIZATION.md",
|
||||
"docs/engineering/current-state.md",
|
||||
"docs/engineering/risk-register.md",
|
||||
"docs/engineering/test-matrix.md",
|
||||
"docs/engineering/hil-strategy.md",
|
||||
"hil_tests/README.md",
|
||||
]
|
||||
TEST_SUITES = [
|
||||
"test_html_escape",
|
||||
"test_json_codec",
|
||||
"test_lora_transport",
|
||||
"test_meter_fault_count",
|
||||
"test_payload_codec",
|
||||
"test_refactor_smoke",
|
||||
"test_security_fuzz",
|
||||
]
|
||||
TRACEABILITY_CONTRACTS = [
|
||||
"LoRa frame format",
|
||||
"CRC validation",
|
||||
"Chunk reassembly",
|
||||
"Payload schema",
|
||||
"Sender-ID validation",
|
||||
"ACK matching",
|
||||
"Time bootstrap",
|
||||
"Retry bounds",
|
||||
"Queue bounds",
|
||||
"Duplicate handling",
|
||||
"MQTT JSON keys",
|
||||
"Home Assistant discovery fields",
|
||||
"CSV compatibility",
|
||||
"HTML escaping",
|
||||
"Web input validation",
|
||||
]
|
||||
|
||||
|
||||
def read_text(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def platformio_envs() -> set[str]:
|
||||
parser = configparser.RawConfigParser()
|
||||
parser.optionxform = str
|
||||
with (ROOT / "platformio.ini").open(encoding="utf-8") as handle:
|
||||
parser.read_file(handle)
|
||||
return {section.split(":", 1)[1] for section in parser.sections() if section.startswith("env:")}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
|
||||
for rel in REQUIRED_PATHS:
|
||||
if not (ROOT / rel).exists():
|
||||
errors.append(f"Missing required documentation or harness file: {rel}")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error)
|
||||
return 1
|
||||
|
||||
envs = platformio_envs()
|
||||
tests_doc = read_text("docs/TESTS.md")
|
||||
readme = read_text("README.md")
|
||||
requirements = read_text("Requirements.md")
|
||||
current_state = read_text("docs/engineering/current-state.md")
|
||||
risk_register = read_text("docs/engineering/risk-register.md")
|
||||
test_matrix = read_text("docs/engineering/test-matrix.md")
|
||||
agents = read_text("AGENTS.md")
|
||||
|
||||
for env in sorted(envs):
|
||||
if f"`{env}`" not in tests_doc:
|
||||
errors.append(f"docs/TESTS.md does not document environment {env}.")
|
||||
for stale in ["lilygo-t3-v1-6-1", "schema v3", "schema `3`"]:
|
||||
if stale in tests_doc:
|
||||
errors.append(f"docs/TESTS.md still contains stale text: {stale}")
|
||||
for suite in TEST_SUITES:
|
||||
if suite not in tests_doc:
|
||||
errors.append(f"docs/TESTS.md does not document suite {suite}.")
|
||||
for contract in TRACEABILITY_CONTRACTS:
|
||||
if contract not in tests_doc or contract not in test_matrix:
|
||||
errors.append(f"Traceability contract missing from docs: {contract}")
|
||||
|
||||
schema_v4_markers = ["schema v4", "schema `4`", "schema `v4`"]
|
||||
for path, text in [("README.md", readme), ("Requirements.md", requirements), ("current-state.md", current_state)]:
|
||||
if not any(marker in text for marker in schema_v4_markers):
|
||||
errors.append(f"{path} does not document payload schema v4.")
|
||||
if "schema v3" in text or "schema `3`" in text:
|
||||
errors.append(f"{path} still documents schema v3.")
|
||||
|
||||
for category in [
|
||||
"Verified Defects",
|
||||
"Probable Risks",
|
||||
"Missing Tests",
|
||||
"Missing Documentation",
|
||||
"Security Decisions",
|
||||
"Hardware-Dependent Questions",
|
||||
]:
|
||||
if category not in risk_register:
|
||||
errors.append(f"risk-register.md missing category: {category}")
|
||||
|
||||
if "python tools/verify.py" not in agents:
|
||||
errors.append("AGENTS.md does not name the canonical verification command.")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}")
|
||||
return 1
|
||||
|
||||
print("Documentation consistency checks passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import py_compile
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
TEXT_SUFFIXES = {
|
||||
".c",
|
||||
".cc",
|
||||
".cpp",
|
||||
".h",
|
||||
".hpp",
|
||||
".ini",
|
||||
".json",
|
||||
".md",
|
||||
".ps1",
|
||||
".py",
|
||||
".txt",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
SKIP_PATHS = {
|
||||
".vscode/c_cpp_properties.json",
|
||||
".vscode/launch.json",
|
||||
}
|
||||
|
||||
|
||||
def tracked_and_untracked_files() -> list[Path]:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(result.stderr, end="")
|
||||
raise SystemExit(result.returncode)
|
||||
return [ROOT / line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def is_text_candidate(path: Path) -> bool:
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
if rel in SKIP_PATHS:
|
||||
return False
|
||||
return path.suffix.lower() in TEXT_SUFFIXES
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
for path in tracked_and_untracked_files():
|
||||
if not path.is_file() or not is_text_candidate(path):
|
||||
continue
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
errors.append(f"{rel}: not valid UTF-8: {exc}")
|
||||
continue
|
||||
for line_no, line in enumerate(text.splitlines(), 1):
|
||||
if line.startswith(("<<<<<<<", "=======", ">>>>>>>")):
|
||||
errors.append(f"{rel}:{line_no}: merge conflict marker")
|
||||
|
||||
for script in sorted((ROOT / "tools").glob("*.py")):
|
||||
try:
|
||||
py_compile.compile(str(script), doraise=True)
|
||||
except py_compile.PyCompileError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if not (ROOT / ".clang-format").exists():
|
||||
print("No .clang-format found; source formatting is not reformatted by this check.")
|
||||
print("Checked text encodings, conflict markers, and Python syntax.")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
BASELINE_PATH = ROOT / "docs" / "engineering" / "size-baseline.json"
|
||||
ENVS = ["production", "debug", "test"]
|
||||
|
||||
|
||||
def file_size(path: Path) -> int:
|
||||
return path.stat().st_size if path.exists() else -1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not BASELINE_PATH.exists():
|
||||
print(f"Missing size baseline: {BASELINE_PATH.relative_to(ROOT)}")
|
||||
return 1
|
||||
|
||||
baseline = json.loads(BASELINE_PATH.read_text(encoding="utf-8"))
|
||||
baseline_envs = baseline.get("environments", {})
|
||||
errors: list[str] = []
|
||||
|
||||
print("Firmware size report:")
|
||||
for env in ENVS:
|
||||
build_dir = ROOT / ".pio" / "build" / env
|
||||
bin_path = build_dir / "firmware.bin"
|
||||
elf_path = build_dir / "firmware.elf"
|
||||
bin_size = file_size(bin_path)
|
||||
elf_size = file_size(elf_path)
|
||||
if bin_size < 0 or elf_size < 0:
|
||||
errors.append(f"{env}: missing firmware artifacts; run python -m platformio run -e {env}")
|
||||
continue
|
||||
entry = baseline_envs.get(env)
|
||||
if not entry:
|
||||
errors.append(f"{env}: missing baseline entry")
|
||||
continue
|
||||
baseline_bin = int(entry["firmware_bin_bytes"])
|
||||
max_bin = int(entry.get("max_firmware_bin_bytes", baseline_bin))
|
||||
delta = bin_size - baseline_bin
|
||||
print(
|
||||
f"- {env}: firmware.bin={bin_size} bytes "
|
||||
f"(baseline {baseline_bin}, delta {delta:+}), firmware.elf={elf_size} bytes"
|
||||
)
|
||||
if bin_size > max_bin:
|
||||
errors.append(f"{env}: firmware.bin {bin_size} exceeds limit {max_bin}")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def command_text(command: list[str]) -> str:
|
||||
parts: list[str] = []
|
||||
for part in command:
|
||||
if any(ch.isspace() for ch in part):
|
||||
parts.append(f'"{part}"')
|
||||
else:
|
||||
parts.append(part)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def run_step(name: str, command: list[str]) -> bool:
|
||||
print(f"\n== {name} ==", flush=True)
|
||||
print(f"$ {command_text(command)}", flush=True)
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
env["PYTHONUTF8"] = "1"
|
||||
env["PIO_FORCE_COLOR"] = "0"
|
||||
result = subprocess.run(command, cwd=ROOT, env=env)
|
||||
if result.returncode != 0:
|
||||
print(f"FAILED: {name} exited with {result.returncode}", flush=True)
|
||||
return False
|
||||
print(f"PASSED: {name}", flush=True)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
python = sys.executable
|
||||
steps = [
|
||||
("repository formatting check", [python, "tools/check_format.py"]),
|
||||
("dependency and PlatformIO policy check", [python, "tools/check_dependencies.py"]),
|
||||
("production build", [python, "-m", "platformio", "run", "-e", "production"]),
|
||||
("debug build", [python, "-m", "platformio", "run", "-e", "debug"]),
|
||||
("test firmware build", [python, "-m", "platformio", "run", "-e", "test"]),
|
||||
(
|
||||
"compile-only embedded Unity tests",
|
||||
[python, "-m", "platformio", "test", "-e", "test", "--without-uploading", "--without-testing"],
|
||||
),
|
||||
("static analysis", [python, "-m", "platformio", "check", "-e", "production", "--fail-on-defect=high"]),
|
||||
("documentation consistency check", [python, "tools/check_docs.py"]),
|
||||
("firmware size check", [python, "tools/check_size.py"]),
|
||||
]
|
||||
|
||||
failures: list[str] = []
|
||||
for name, command in steps:
|
||||
if not run_step(name, command):
|
||||
failures.append(name)
|
||||
|
||||
if failures:
|
||||
print("\nVerification failed:")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("\nVerification passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user