diff --git a/Makefile b/Makefile
index 5d75a8d..3f03976 100644
--- a/Makefile
+++ b/Makefile
@@ -1,22 +1,56 @@
# Rapport de couverture des tests Python
coverage:
- coverage run -m pytest
- coverage html -d docs/coverage_report
-.PHONY: fw hw s0 docs
+ python3 -m coverage run -m pytest
+ python3 -m coverage html -d docs/coverage_report
+
+CAD_STACK ?= ./tools/hw/cad_stack.sh
+CAD_ARGS ?=
+
+.PHONY: coverage fw hw s0 docs compliance cad-up cad-down cad-ps cad-build cad-doctor cad-mcp cad-kicad cad-freecad cad-pio
s0:
- python tools/cockpit/cockpit.py gate_s0
+ python3 tools/cockpit/cockpit.py gate_s0
fw:
- python tools/cockpit/cockpit.py fw
+ python3 tools/cockpit/cockpit.py fw
hw:
@echo "usage: make hw SCHEM=hardware/kicad/
/
.kicad_sch"
bash tools/hw/hw_check.sh $(SCHEM)
docs:
- python -m pip install -U mkdocs
+ python3 -m pip install -U mkdocs
mkdocs build --strict
compliance:
- python tools/compliance/validate.py --strict
+ python3 tools/compliance/validate.py --strict
+
+cad-up:
+ $(CAD_STACK) up $(CAD_ARGS)
+
+cad-down:
+ $(CAD_STACK) down
+
+cad-ps:
+ $(CAD_STACK) ps
+
+cad-build:
+ $(CAD_STACK) build $(CAD_ARGS)
+
+cad-doctor:
+ $(CAD_STACK) doctor
+
+cad-mcp:
+ $(CAD_STACK) mcp $(CAD_ARGS)
+
+cad-kicad:
+ @if [ -z "$(CAD_ARGS)" ]; then echo "usage: make cad-kicad CAD_ARGS='version'"; exit 1; fi
+ $(CAD_STACK) kicad-cli $(CAD_ARGS)
+
+cad-freecad:
+ @if [ -z "$(CAD_ARGS)" ]; then echo "usage: make cad-freecad CAD_ARGS='-c \"import FreeCAD; print(FreeCAD.Version())\"'"; exit 1; fi
+ $(CAD_STACK) freecad-cmd $(CAD_ARGS)
+
+cad-pio:
+ @if [ -z "$(CAD_ARGS)" ]; then echo "usage: make cad-pio CAD_ARGS='system info'"; exit 1; fi
+ $(CAD_STACK) pio $(CAD_ARGS)
diff --git a/gh_setup_and_patches/setup_repo.sh b/gh_setup_and_patches/setup_repo.sh
index f9dfcb9..14b19d0 100755
--- a/gh_setup_and_patches/setup_repo.sh
+++ b/gh_setup_and_patches/setup_repo.sh
@@ -28,6 +28,7 @@ BRANCH="${BRANCH:-main}"
ENABLE_DISCUSSIONS="${ENABLE_DISCUSSIONS:-0}"
REQUIRE_BUILD_CHECKS="${REQUIRE_BUILD_CHECKS:-1}"
DRY_RUN="${DRY_RUN:-0}"
+REQUIRED_CONTEXTS_JSON="${REQUIRED_CONTEXTS_JSON:-}"
if [[ -z "${REPO_FULL}" ]]; then
echo "Usage: $0 " >&2
@@ -37,11 +38,32 @@ fi
OWNER="${REPO_FULL%/*}"
REPO="${REPO_FULL#*/}"
-run() {
+shell_join() {
+ local quoted=()
+ local arg
+ for arg in "$@"; do
+ quoted+=("$(printf '%q' "$arg")")
+ done
+ printf '%s' "${quoted[*]}"
+}
+
+run_cmd() {
if [[ "${DRY_RUN}" == "1" ]]; then
- echo "[dry-run] $*"
+ printf '[dry-run] '
+ shell_join "$@"
+ printf '\n'
else
- eval "$@"
+ "$@"
+ fi
+}
+
+run_cmd_quiet() {
+ if [[ "${DRY_RUN}" == "1" ]]; then
+ printf '[dry-run] '
+ shell_join "$@"
+ printf '\n'
+ else
+ "$@" >/dev/null
fi
}
@@ -52,7 +74,7 @@ need_gh() {
create_label() {
local name="$1"; local color="$2"; local desc="$3"
- run "gh label create \"${name}\" -R \"${REPO_FULL}\" --color \"${color}\" --description \"${desc}\" --force >/dev/null"
+ run_cmd_quiet gh label create "${name}" -R "${REPO_FULL}" --color "${color}" --description "${desc}" --force
}
setup_labels() {
@@ -120,39 +142,55 @@ enable_discussions() {
repository(owner:$owner, name:$name) { id hasDiscussionsEnabled }
}' --jq '.data.repository.id')"
- run "gh api graphql -f repositoryId=\"${repo_id}\" -F enabled=true -f query='\
- mutation($repositoryId:ID!, $enabled:Boolean!) {\
- updateRepository(input:{repositoryId:$repositoryId, hasDiscussionsEnabled:$enabled}) {\
- repository { name hasDiscussionsEnabled }\
- }\
- }' >/dev/null"
+ local mutation='mutation($repositoryId:ID!, $enabled:Boolean!) {
+ updateRepository(input:{repositoryId:$repositoryId, hasDiscussionsEnabled:$enabled}) {
+ repository { name hasDiscussionsEnabled }
+ }
+ }'
+ run_cmd_quiet gh api graphql -f repositoryId="${repo_id}" -F enabled=true -f query="${mutation}"
echo "==> Discussions enabled."
}
+load_required_contexts() {
+ if [[ -n "${REQUIRED_CONTEXTS_JSON}" ]]; then
+ python3 - "${REQUIRED_CONTEXTS_JSON}" <<'PY'
+import json
+import sys
+
+payload = json.loads(sys.argv[1])
+if not isinstance(payload, list) or not all(isinstance(item, str) and item.strip() for item in payload):
+ raise SystemExit("REQUIRED_CONTEXTS_JSON must be a JSON array of non-empty strings")
+for item in payload:
+ print(item)
+PY
+ return 0
+ fi
+
+ printf '%s\n' \
+ "Badges & Coverage / badges" \
+ "Secret Scanning / secret_scan" \
+ "Repo State / repo-state"
+
+ if [[ "${REQUIRE_BUILD_CHECKS}" == "1" ]]; then
+ printf '%s\n' \
+ "API Contract & Integration Testing / api_contract" \
+ "Evidence Pack Validation / evidence_pack"
+ fi
+}
+
setup_branch_protection() {
echo "==> Configuring branch protection for ${REPO_FULL}:${BRANCH} ..."
# Required check contexts (GitHub Actions job names):
# These MUST match the check names you see in PR -> Checks.
# If you change workflow/job names, update this list.
- local -a contexts
- contexts+=("PR Label Enforcement / label-enforcement")
- contexts+=("Scope Guard / guard")
-
- if [[ "${REQUIRE_BUILD_CHECKS}" == "1" ]]; then
- contexts+=("Firmware CI / pio")
- contexts+=("Hardware CI (KiCad) / hw")
- contexts+=("Compliance Gate / validate")
- fi
+ local -a contexts=()
+ mapfile -t contexts < <(load_required_contexts)
# JSON array for contexts
local contexts_json
- contexts_json="$(printf '%s\n' "${contexts[@]}" | python3 - <<'PY'
-import sys, json
-print(json.dumps([l.rstrip('\n') for l in sys.stdin if l.strip()]))
-PY
- )"
+ contexts_json="$(printf '%s\n' "${contexts[@]}" | python3 -c 'import json, sys; print(json.dumps([line.rstrip("\n") for line in sys.stdin if line.strip()]))')"
local body
body="$(cat < str:
s = re.sub(r"`[^`]*`", "", s)
return s
+def remove_html_blocks(s: str) -> str:
+ """
+ Remove paired HTML-like blocks with their content.
+
+ This intentionally fails closed: if a user wraps sensitive content in a tag
+ such as `token`, the content is discarded with the tag.
+ """
+ return re.sub(r"<([A-Za-z][A-Za-z0-9:_-]*)\b[^>]*>.*?\1\s*>", "", s, flags=re.DOTALL)
+
def strip_html_tags(s: str) -> str:
"""Remove all HTML tags from the input."""
return re.sub(r"<[^>]+>", "", s)
@@ -83,7 +92,7 @@ def sanitize_text(s: str) -> str:
1. Remove HTML comments (already done by `strip_html_comments`).
2. Remove fenced/indented/inline code blocks.
- 3. Strip remaining HTML tags.
+ 3. Remove HTML-like blocks with their content, then strip remaining tags.
4. Neutralize mentions, issue references and email addresses.
5. Remove lines with suspicious shell patterns.
6. Strip URLs.
@@ -93,6 +102,7 @@ def sanitize_text(s: str) -> str:
"""
s = strip_html_comments(s)
s = remove_code_blocks(s)
+ s = remove_html_blocks(s)
s = strip_html_tags(s)
s = neutralize_mentions_and_refs(s)
s = remove_suspicious_patterns(s)
diff --git a/tools/auto_check_ci_cd.py b/tools/auto_check_ci_cd.py
index e9c05c4..921734a 100644
--- a/tools/auto_check_ci_cd.py
+++ b/tools/auto_check_ci_cd.py
@@ -1,22 +1,74 @@
-import subprocess
+#!/usr/bin/env python3
+from __future__ import annotations
-def check_all_targets(targets):
- results = {}
- for target in targets:
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+REPORT_PATH = ROOT / "docs" / "evidence" / "ci_cd_audit_summary.json"
+
+
+def run_step(args: list[str]) -> dict:
+ proc = subprocess.run(
+ [sys.executable, *args],
+ cwd=str(ROOT),
+ capture_output=True,
+ text=True,
+ )
+ return {
+ "command": [sys.executable, *args],
+ "returncode": proc.returncode,
+ "stdout": proc.stdout.strip(),
+ "stderr": proc.stderr.strip(),
+ }
+
+
+def check_all_targets() -> tuple[dict, bool]:
+ report = {
+ "targets": {},
+ "compliance": run_step(["tools/compliance/validate.py", "--strict"]),
+ }
+ failed = report["compliance"]["returncode"] != 0
+
+ target_steps = {
+ "esp": [
+ ["tools/build_firmware.py", "esp"],
+ ["tools/collect_evidence.py", "esp"],
+ ["tools/verify_evidence.py", "esp"],
+ ],
+ "linux": [
+ ["tools/test_firmware.py", "linux"],
+ ["tools/collect_evidence.py", "linux"],
+ ["tools/verify_evidence.py", "linux"],
+ ],
+ }
+
+ for target, steps in target_steps.items():
print(f"\n--- Vérification {target} ---")
- try:
- subprocess.run(["python", "tools/build_firmware.py", target], check=True)
- subprocess.run(["python", "tools/test_firmware.py", target], check=True)
- subprocess.run(["python", "tools/collect_evidence.py", target], check=True)
- evidence = subprocess.run(["python", "tools/verify_evidence.py", target], capture_output=True, text=True)
- results[target] = evidence.stdout.strip()
- except subprocess.CalledProcessError as e:
- results[target] = f"Erreur: {e}"
- return results
+ results = []
+ for step in steps:
+ result = run_step(step)
+ results.append(result)
+ if result["stdout"]:
+ print(result["stdout"])
+ if result["stderr"]:
+ print(result["stderr"], file=sys.stderr)
+ report["targets"][target] = results
+ failed = failed or any(item["returncode"] != 0 for item in results)
+
+ REPORT_PATH.parent.mkdir(parents=True, exist_ok=True)
+ REPORT_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ return report, failed
+
if __name__ == '__main__':
- targets = ["esp", "stm", "linux"]
- report = check_all_targets(targets)
+ report, failed = check_all_targets()
print("\n=== Rapport de vérification ===")
- for tgt, res in report.items():
- print(f"{tgt}: {res}")
+ print(f"compliance: rc={report['compliance']['returncode']}")
+ for target, steps in report["targets"].items():
+ rc = max(item["returncode"] for item in steps)
+ print(f"{target}: rc={rc}")
+ print(f"rapport: {REPORT_PATH}")
+ raise SystemExit(1 if failed else 0)
diff --git a/tools/build_firmware.py b/tools/build_firmware.py
index f9b919c..5786aa4 100644
--- a/tools/build_firmware.py
+++ b/tools/build_firmware.py
@@ -1,20 +1,29 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from tools.ci_runtime import pio_mode, resolve_target, resolved_pio_runner, run_platformio_step
+
+
+def build_firmware(target: str) -> int:
+ spec = resolve_target(target, expected_mode="build")
+ print(
+ f"Build target '{spec.requested}' via PlatformIO env '{spec.env}' "
+ f"(mode={pio_mode()}, runner={resolved_pio_runner(spec, 'build')})"
+ )
+ rc = run_platformio_step(spec, "build")
+ if rc == 0:
+ print(f"Build terminé pour {spec.requested}")
+ return rc
-def build_firmware(target):
- # Exemple minimal : build pour chaque cible
- if target == 'esp':
- print('Build ESP...')
- # Appel PlatformIO ou script ESP
- elif target == 'stm':
- print('Build STM...')
- # Appel PlatformIO ou script STM
- elif target == 'linux':
- print('Build Linux...')
- # Appel QEMU ou make Linux
- else:
- print('Cible inconnue')
- sys.exit(1)
- print(f'Build terminé pour {target}')
if __name__ == '__main__':
- build_firmware(sys.argv[1])
+ if len(sys.argv) != 2:
+ raise SystemExit("usage: build_firmware.py ")
+ raise SystemExit(build_firmware(sys.argv[1]))
diff --git a/tools/ci/ci_audit.py b/tools/ci/ci_audit.py
index 26da6a4..934acc2 100644
--- a/tools/ci/ci_audit.py
+++ b/tools/ci/ci_audit.py
@@ -4,15 +4,17 @@
import glob
import yaml
import json
-from datetime import datetime
+import sys
+from datetime import datetime, timezone
workflows = glob.glob('.github/workflows/*.yml')
report = {
'schemaVersion': 1,
'label': 'Audit CI/CD',
- 'timestamp': datetime.utcnow().isoformat() + 'Z',
+ 'timestamp': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
'workflows': {},
+ 'invalid_workflows': {},
'summary': {
'tests': 0,
'coverage': 0,
@@ -20,13 +22,23 @@ report = {
'compliance': 0,
'docs': 0,
'hardware': 0,
- 'ai': 0
+ 'ai': 0,
+ 'invalid': 0
}
}
for wf in workflows:
- with open(wf, 'r') as f:
- data = yaml.safe_load(f)
+ with open(wf, 'r', encoding='utf-8') as f:
+ try:
+ data = yaml.safe_load(f)
+ except yaml.YAMLError as exc:
+ report['invalid_workflows'][wf] = str(exc)
+ report['summary']['invalid'] += 1
+ continue
+ if not isinstance(data, dict):
+ report['invalid_workflows'][wf] = 'workflow did not parse to a mapping'
+ report['summary']['invalid'] += 1
+ continue
wf_name = data.get('name', wf)
jobs = data.get('jobs', {})
wf_info = {'tests': False, 'coverage': False, 'badges': False, 'compliance': False, 'docs': False, 'hardware': False, 'ai': False}
@@ -57,3 +69,7 @@ with open('docs/ci-audit-summary.json', 'w') as f:
json.dump(report, f, indent=2)
print(f"Rapport d’audit CI/CD généré : docs/ci-audit-summary.json")
+if report['invalid_workflows']:
+ for wf, err in report['invalid_workflows'].items():
+ print(f"Workflow invalide: {wf}: {err}", file=sys.stderr)
+ raise SystemExit(1)
diff --git a/tools/ci_runtime.py b/tools/ci_runtime.py
new file mode 100644
index 0000000..46132da
--- /dev/null
+++ b/tools/ci_runtime.py
@@ -0,0 +1,187 @@
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+FIRMWARE_DIR = ROOT / "firmware"
+EVIDENCE_ROOT = ROOT / "docs" / "evidence"
+CAD_STACK = ROOT / "tools" / "hw" / "cad_stack.sh"
+PIO_MODE_ENV = "KILL_LIFE_PIO_MODE"
+
+BUILD_ENV_BY_TARGET = {
+ "esp": "esp32s3_arduino",
+ "esp32s3_arduino": "esp32s3_arduino",
+ "esp32_arduino": "esp32_arduino",
+}
+
+TEST_ENV_BY_TARGET = {
+ "linux": "native",
+ "native": "native",
+}
+
+
+@dataclass(frozen=True)
+class TargetSpec:
+ requested: str
+ env: str
+ mode: str
+
+
+def now_utc() -> str:
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def write_json(path: Path, payload: dict) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+
+
+def relative_to_root(path: Path) -> str:
+ try:
+ return str(path.resolve().relative_to(ROOT)).replace("\\", "/")
+ except ValueError:
+ return str(path.resolve())
+
+
+def ensure_evidence_dir(target: str) -> Path:
+ path = EVIDENCE_ROOT / target
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def pio_mode() -> str:
+ value = os.environ.get(PIO_MODE_ENV, "auto").strip().lower() or "auto"
+ if value not in {"auto", "native", "container"}:
+ raise SystemExit(
+ f"Unsupported {PIO_MODE_ENV}='{value}'. Supported values: auto, native, container"
+ )
+ return value
+
+
+def native_pio_available() -> bool:
+ return shutil.which("pio") is not None
+
+
+def container_pio_available() -> bool:
+ return CAD_STACK.exists() and shutil.which("bash") is not None and shutil.which("docker") is not None
+
+
+def firmware_project_dir() -> str:
+ return relative_to_root(FIRMWARE_DIR)
+
+
+def resolve_target(target: str, expected_mode: str | None = None) -> TargetSpec:
+ if target in BUILD_ENV_BY_TARGET:
+ spec = TargetSpec(requested=target, env=BUILD_ENV_BY_TARGET[target], mode="build")
+ elif target in TEST_ENV_BY_TARGET:
+ spec = TargetSpec(requested=target, env=TEST_ENV_BY_TARGET[target], mode="test")
+ else:
+ supported = sorted({*BUILD_ENV_BY_TARGET.keys(), *TEST_ENV_BY_TARGET.keys()})
+ raise SystemExit(f"Unsupported target '{target}'. Supported targets: {', '.join(supported)}")
+
+ if expected_mode and spec.mode != expected_mode:
+ raise SystemExit(f"Target '{target}' only supports mode '{spec.mode}', not '{expected_mode}'")
+ return spec
+
+
+def platformio_command(spec: TargetSpec, step: str) -> tuple[list[str], Path, str]:
+ mode = pio_mode()
+ native_available = native_pio_available()
+ use_container = mode == "container" or (mode == "auto" and not native_available)
+
+ pio_subcommand = "run" if step == "build" else "test"
+ project_dir = firmware_project_dir()
+ args = [pio_subcommand, "-d", project_dir, "-e", spec.env]
+
+ if use_container:
+ return ["bash", str(CAD_STACK), "pio", *args], ROOT, "cad-stack-container"
+
+ return ["pio", *args], ROOT, "native-pio"
+
+
+def resolved_pio_runner(spec: TargetSpec, step: str) -> str:
+ return platformio_command(spec, step)[2]
+
+
+def run_logged_command(spec: TargetSpec, step: str, cmd: list[str], cwd: Path = FIRMWARE_DIR) -> int:
+ evidence_dir = ensure_evidence_dir(spec.requested)
+ runner = "subprocess"
+ try:
+ result = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
+ stdout = result.stdout
+ stderr = result.stderr
+ returncode = result.returncode
+ except FileNotFoundError as exc:
+ stdout = ""
+ stderr = str(exc)
+ returncode = 127
+ (evidence_dir / f"{step}.stdout.txt").write_text(stdout, encoding="utf-8")
+ (evidence_dir / f"{step}.stderr.txt").write_text(stderr, encoding="utf-8")
+ write_json(
+ evidence_dir / f"{step}.result.json",
+ {
+ "target": spec.requested,
+ "env": spec.env,
+ "mode": spec.mode,
+ "step": step,
+ "cwd": relative_to_root(cwd),
+ "command": cmd,
+ "runner": runner,
+ "returncode": returncode,
+ "generated_at_utc": now_utc(),
+ },
+ )
+ return returncode
+
+
+def run_platformio_step(spec: TargetSpec, step: str) -> int:
+ cmd, cwd, runner = platformio_command(spec, step)
+ evidence_dir = ensure_evidence_dir(spec.requested)
+ try:
+ result = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
+ stdout = result.stdout
+ stderr = result.stderr
+ returncode = result.returncode
+ except FileNotFoundError as exc:
+ stdout = ""
+ stderr = str(exc)
+ returncode = 127
+
+ (evidence_dir / f"{step}.stdout.txt").write_text(stdout, encoding="utf-8")
+ (evidence_dir / f"{step}.stderr.txt").write_text(stderr, encoding="utf-8")
+ write_json(
+ evidence_dir / f"{step}.result.json",
+ {
+ "target": spec.requested,
+ "env": spec.env,
+ "mode": spec.mode,
+ "step": step,
+ "cwd": relative_to_root(cwd),
+ "project_dir": firmware_project_dir(),
+ "command": cmd,
+ "runner": runner,
+ "returncode": returncode,
+ "generated_at_utc": now_utc(),
+ },
+ )
+ return returncode
+
+
+def collect_artifacts(spec: TargetSpec) -> list[str]:
+ base = FIRMWARE_DIR / ".pio" / "build" / spec.env
+ if spec.mode == "build":
+ candidates = [
+ base / "firmware.bin",
+ base / "firmware.elf",
+ base / "firmware.map",
+ base,
+ ]
+ else:
+ candidates = [base]
+ return [relative_to_root(path) for path in candidates if path.exists()]
diff --git a/tools/collect_evidence.py b/tools/collect_evidence.py
index 4c453ad..3c59807 100644
--- a/tools/collect_evidence.py
+++ b/tools/collect_evidence.py
@@ -1,11 +1,51 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from tools.ci_runtime import collect_artifacts, ensure_evidence_dir, now_utc, resolve_target, write_json
+
+
+def collect_evidence(target: str) -> bool:
+ spec = resolve_target(target)
+ evidence_dir = ensure_evidence_dir(spec.requested)
+ prefix = "build" if spec.mode == "build" else "test"
+ required = [
+ evidence_dir / f"{prefix}.result.json",
+ evidence_dir / f"{prefix}.stdout.txt",
+ evidence_dir / f"{prefix}.stderr.txt",
+ ]
+ artifacts = collect_artifacts(spec)
+ missing = [path.name for path in required if not path.exists()]
+ if not artifacts:
+ missing.append("artifacts")
+
+ summary = {
+ "target": spec.requested,
+ "env": spec.env,
+ "mode": spec.mode,
+ "generated_at_utc": now_utc(),
+ "required_files": [path.name for path in required],
+ "artifacts": artifacts,
+ "status": "ok" if not missing else "incomplete",
+ "missing": missing,
+ }
+ write_json(evidence_dir / "summary.json", summary)
+
+ if missing:
+ print(f"Evidence pack incomplet pour {spec.requested}: {', '.join(missing)}")
+ return False
+
+ print(f"Evidence pack généré pour {spec.requested}: {evidence_dir}")
+ return True
-def collect_evidence(target):
- # Exemple minimal : collecte logs, binaries, rapports
- print(f'Collecte evidence pack pour {target}...')
- # Simuler la collecte
- # À compléter avec la vraie logique
- print(f'Evidence pack généré pour {target}')
if __name__ == '__main__':
- collect_evidence(sys.argv[1])
+ if len(sys.argv) != 2:
+ raise SystemExit("usage: collect_evidence.py ")
+ raise SystemExit(0 if collect_evidence(sys.argv[1]) else 1)
diff --git a/tools/compliance/scan_rfc2119.py b/tools/compliance/scan_rfc2119.py
index 0492354..28fc4af 100644
--- a/tools/compliance/scan_rfc2119.py
+++ b/tools/compliance/scan_rfc2119.py
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
-# Scan RFC2119 conformité dans docs/specs/*.md et génère docs/rfc2119-summary.json
+# Scan RFC2119 conformité dans specs/*.md et génère docs/rfc2119-summary.json
-import glob
import json
import re
-from datetime import datetime
+from datetime import datetime, timezone
+from pathlib import Path
-files = glob.glob('docs/specs/*.md')
+files = sorted(Path("specs").rglob("*.md"))
rfc_terms = ['MUST', 'SHOULD', 'MAY']
forbidden = [r'must', r'should', r'may', r'Must', r'Should', r'May']
@@ -16,24 +16,25 @@ summary = {
'label': 'Conformité RFC2119',
'counts': {t: 0 for t in rfc_terms},
'forbidden': [],
- 'timestamp': datetime.utcnow().isoformat() + 'Z',
+ 'timestamp': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
'files': {}
}
for f in files:
- with open(f, 'r') as fd:
+ with open(f, 'r', encoding='utf-8') as fd:
content = fd.read()
- summary['files'][f] = {'MUST': 0, 'SHOULD': 0, 'MAY': 0, 'forbidden': []}
+ key = str(f).replace("\\", "/")
+ summary['files'][key] = {'MUST': 0, 'SHOULD': 0, 'MAY': 0, 'forbidden': []}
for t in rfc_terms:
summary['counts'][t] += len(re.findall(rf'\b{t}\b', content))
- summary['files'][f][t] = len(re.findall(rf'\b{t}\b', content))
+ summary['files'][key][t] = len(re.findall(rf'\b{t}\b', content))
for forb in forbidden:
matches = re.findall(rf'\b{forb}\b', content)
if matches:
summary['forbidden'] += matches
- summary['files'][f]['forbidden'] += matches
+ summary['files'][key]['forbidden'] += matches
-with open('docs/rfc2119-summary.json', 'w') as fd:
+with open('docs/rfc2119-summary.json', 'w', encoding='utf-8') as fd:
json.dump(summary, fd, indent=2)
print(f"Conformité RFC2119 : {summary['counts']} | Interdits : {summary['forbidden']}")
diff --git a/tools/compliance/use_profile.py b/tools/compliance/use_profile.py
index 06c672d..3c8063f 100644
--- a/tools/compliance/use_profile.py
+++ b/tools/compliance/use_profile.py
@@ -1,20 +1,30 @@
#!/usr/bin/env python3
"""Switch active compliance profile."""
-from pathlib import Path
import argparse
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
from tools.compliance.common import repo_path, save_yaml
def main():
ap = argparse.ArgumentParser()
- ap.add_argument("profile", help="Profile name (e.g., prototype, iot_wifi_eu)")
+ ap.add_argument("profile", nargs="?", help="Profile name (e.g., prototype, iot_wifi_eu)")
+ ap.add_argument("--profile", dest="profile_flag", help="Profile name (alias for the positional argument)")
args = ap.parse_args()
+ profile_name = args.profile_flag or args.profile
+ if not profile_name:
+ ap.error("a profile name is required")
- p = repo_path(f"compliance/profiles/{args.profile}.yaml")
+ p = repo_path(f"compliance/profiles/{profile_name}.yaml")
if not p.exists():
- raise SystemExit(f"ERROR: unknown profile: {args.profile} (missing {p})")
+ raise SystemExit(f"ERROR: unknown profile: {profile_name} (missing {p})")
- save_yaml(repo_path("compliance/active_profile.yaml"), {"profile": args.profile})
- print(f"Active compliance profile = {args.profile}")
+ save_yaml(repo_path("compliance/active_profile.yaml"), {"profile": profile_name})
+ print(f"Active compliance profile = {profile_name}")
if __name__ == "__main__":
main()
diff --git a/tools/compliance/validate.py b/tools/compliance/validate.py
index a8c3df3..e4d4a9a 100644
--- a/tools/compliance/validate.py
+++ b/tools/compliance/validate.py
@@ -9,7 +9,11 @@
from pathlib import Path
import argparse
import glob
-import os
+import sys
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
from tools.compliance.common import (
repo_path, load_active_profile_name, load_profile, load_catalog, load_yaml
diff --git a/tools/mistral/apply_safe_patch.py b/tools/mistral/apply_safe_patch.py
index 5162fd6..a7d6f75 100644
--- a/tools/mistral/apply_safe_patch.py
+++ b/tools/mistral/apply_safe_patch.py
@@ -11,13 +11,20 @@ from __future__ import annotations
import argparse
import json
+import re
+import sys
from pathlib import Path
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
from jsonschema import Draft202012Validator
from tools.mistral.scope_allowlists import is_path_allowed, explain_scope
SCHEMA_PATH = Path(__file__).parent / "schemas" / "safe_patch.schema.json"
+WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[/\\]")
def safe_write(path: Path, content: str) -> None:
@@ -25,6 +32,27 @@ def safe_write(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
+def normalize_relative_path(raw_path: str) -> str:
+ rel = raw_path.replace("\\", "/").strip()
+ while rel.startswith("./"):
+ rel = rel[2:]
+ if not rel or rel.startswith("/") or WINDOWS_DRIVE_RE.match(rel):
+ raise SystemExit(f"Refusing absolute patch path: {raw_path}")
+ parts = rel.split("/")
+ if any(part in ("", ".", "..") for part in parts):
+ raise SystemExit(f"Refusing unsafe patch path: {raw_path}")
+ return "/".join(parts)
+
+
+def resolve_destination(root: Path, rel: str) -> Path:
+ dst = (root / rel).resolve()
+ try:
+ dst.relative_to(root)
+ except ValueError as exc:
+ raise SystemExit(f"Refusing out-of-repo path: {rel}") from exc
+ return dst
+
+
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--scope", required=True, help="ai:spec|ai:plan|ai:tasks|ai:impl|ai:qa|ai:docs")
@@ -48,7 +76,7 @@ def main() -> int:
edits = patch.get("edits", [])
blocked = []
for e in edits:
- p = e["path"].replace("\\", "/").lstrip("/")
+ p = normalize_relative_path(e["path"])
if not is_path_allowed(args.scope, p):
blocked.append(p)
@@ -64,9 +92,9 @@ def main() -> int:
applied = []
for e in edits:
- rel = e["path"].replace("\\", "/").lstrip("/")
+ rel = normalize_relative_path(e["path"])
action = e["action"]
- dst = root / rel
+ dst = resolve_destination(root, rel)
if action in ("create", "update"):
safe_write(dst, e.get("content", ""))
@@ -74,6 +102,8 @@ def main() -> int:
elif action == "delete":
if not args.allow_delete:
raise SystemExit(f"Refusing to delete {rel} (pass --allow-delete)")
+ if dst.is_dir():
+ raise SystemExit(f"Refusing to delete directory {rel}")
if dst.exists():
dst.unlink()
applied.append(f"delete: {rel}")
diff --git a/tools/mistral/schemas/safe_patch.schema.json b/tools/mistral/schemas/safe_patch.schema.json
index 4d8b89b..c7e51cd 100644
--- a/tools/mistral/schemas/safe_patch.schema.json
+++ b/tools/mistral/schemas/safe_patch.schema.json
@@ -25,7 +25,8 @@
"properties": {
"path": {
"type": "string",
- "minLength": 1
+ "minLength": 1,
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$)).+$"
},
"action": {
"type": "string",
@@ -90,4 +91,4 @@
"type": "string"
}
}
-}
\ No newline at end of file
+}
diff --git a/tools/mistral/scope_allowlists.py b/tools/mistral/scope_allowlists.py
index 6c2521c..7cb968c 100644
--- a/tools/mistral/scope_allowlists.py
+++ b/tools/mistral/scope_allowlists.py
@@ -5,71 +5,4 @@ This is NOT a replacement for CI scope guards; it is a local "pre-flight" safety
"""
from __future__ import annotations
-import fnmatch
-from typing import List
-
-
-DENY_GLOBS = [
- ".git/**",
- ".github/workflows/**",
- ".github/actions/**",
- "**/.env",
- "**/*.key",
- "**/*secret*",
- "**/*token*",
-]
-
-ALLOWED_BY_SCOPE = {
- "ai:spec": [
- "specs/**",
- "docs/**",
- "README.md",
- ".github/copilot-instructions.md",
- ".github/copilot/**",
- ".github/prompts/**",
- ],
- "ai:plan": [
- "specs/**",
- "docs/**",
- "README.md",
- ],
- "ai:tasks": [
- "specs/**",
- "docs/**",
- ],
- "ai:docs": [
- "docs/**",
- "README.md",
- ".github/copilot-instructions.md",
- ".github/copilot/**",
- ".github/prompts/**",
- ],
- "ai:impl": [
- "firmware/**",
- "docs/**",
- "specs/**",
- "tools/**",
- ],
- "ai:qa": [
- "firmware/**",
- "tools/**",
- "docs/**",
- "specs/**",
- ],
-}
-
-def _matches_any(path: str, globs: List[str]) -> bool:
- return any(fnmatch.fnmatch(path, g) for g in globs)
-
-def is_path_allowed(scope: str, path: str) -> bool:
- path = path.replace("\\", "/").lstrip("/")
- if _matches_any(path, DENY_GLOBS):
- return False
- allow = ALLOWED_BY_SCOPE.get(scope)
- if not allow:
- return False
- return _matches_any(path, allow)
-
-def explain_scope(scope: str) -> str:
- allow = ALLOWED_BY_SCOPE.get(scope, [])
- return f"{scope} allows: " + ", ".join(allow)
+from tools.scope_policy import explain_scope, is_path_allowed
diff --git a/tools/scope_guard.py b/tools/scope_guard.py
index c888dad..f798246 100644
--- a/tools/scope_guard.py
+++ b/tools/scope_guard.py
@@ -1,5 +1,3 @@
-# Easter Egg musique expérimentale
-# _« Le scope guard veille comme Éliane Radigue : lent, précis, et toujours prêt à vibrer en silence. »_
#!/usr/bin/env python3
"""
Scope guard for AI‑driven pull requests.
@@ -28,30 +26,15 @@ import json
import os
import subprocess
import sys
+from pathlib import Path
from typing import List
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
-# Mapping of ai:* labels to allowed directory prefixes. The keys should
-# correspond exactly to the labels used in your workflow. These prefixes are
-# relative to the repository root. If a file's path starts with any of
-# these prefixes or matches exactly, it is considered allowed.
-ALLOWLIST = {
- "ai:spec": ["specs/", "docs/", "README.md"],
- "ai:plan": ["specs/", "docs/", "README.md"],
- "ai:tasks": ["specs/features/", "docs/", "README.md"],
- "ai:impl": ["firmware/", "tools/hw/", "tools/ai/", "tools/compliance/", "docs/auto_generated/", "README.md"],
- "ai:qa": ["firmware/test/", "docs/", "README.md"],
- "ai:docs": ["docs/", "README.md"],
-}
+from tools.scope_policy import explain_scope, is_path_allowed
-# Files or directories that must never be modified by AI automations. If a
-# modified file starts with any of these patterns, the guard fails.
-DENYLIST = [
- ".github/workflows/", # workflows are controlled manually
- "openclaw/", # openclaw configuration is managed separately
- "tools/ai/sanitize_issue.py", # sanitation logic is security‑sensitive
- "tools/scope_guard.py", # this script itself
-]
def get_labels_from_event() -> List[str]:
"""Return a list of label names from the GitHub event JSON, if present."""
@@ -111,18 +94,7 @@ def get_changed_files() -> List[str]:
def is_allowed(file_path: str, label: str) -> bool:
"""Check if a file_path is allowed for the given label."""
- # Denylist check first
- for deny in DENYLIST:
- if file_path.startswith(deny):
- return False
- allowed_prefixes = ALLOWLIST.get(label)
- if not allowed_prefixes:
- # Unknown label uses the default 'ai:impl'
- allowed_prefixes = ALLOWLIST.get("ai:impl", [])
- for prefix in allowed_prefixes:
- if file_path == prefix or file_path.startswith(prefix):
- return True
- return False
+ return is_path_allowed(label, file_path)
def main() -> int:
@@ -141,9 +113,9 @@ def main() -> int:
print(f" - {f}")
print("See tools/scope_guard.py for the policy details.")
return 1
- print(f"Scope guard passed. Label '{label}' allows changes to: {', '.join(ALLOWLIST.get(label, []))}")
+ print(f"Scope guard passed. {explain_scope(label)}")
return 0
if __name__ == "__main__":
- sys.exit(main())
\ No newline at end of file
+ sys.exit(main())
diff --git a/tools/scope_policy.py b/tools/scope_policy.py
new file mode 100644
index 0000000..2c97bb5
--- /dev/null
+++ b/tools/scope_policy.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+import fnmatch
+import re
+
+DENY_GLOBS = [
+ ".git/**",
+ ".github/workflows/**",
+ ".github/actions/**",
+ "**/.env",
+ "**/*.key",
+ "**/*secret*",
+ "**/*token*",
+]
+
+ALLOWED_BY_SCOPE = {
+ "ai:spec": [
+ "specs/**",
+ "docs/**",
+ "README.md",
+ ".github/copilot-instructions.md",
+ ".github/copilot/**",
+ ".github/prompts/**",
+ ],
+ "ai:plan": [
+ "specs/**",
+ "docs/**",
+ "README.md",
+ ],
+ "ai:tasks": [
+ "specs/**",
+ "docs/**",
+ ],
+ "ai:docs": [
+ "docs/**",
+ "README.md",
+ ".github/copilot-instructions.md",
+ ".github/copilot/**",
+ ".github/prompts/**",
+ ],
+ "ai:impl": [
+ "firmware/**",
+ "docs/**",
+ "specs/**",
+ "tools/**",
+ ],
+ "ai:qa": [
+ "firmware/**",
+ "tools/**",
+ "docs/**",
+ "specs/**",
+ ],
+}
+
+WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:/")
+
+
+def normalize_path(path: str) -> str:
+ path = path.replace("\\", "/").strip()
+ while path.startswith("./"):
+ path = path[2:]
+ return path
+
+
+def has_safe_segments(path: str) -> bool:
+ if not path or path.startswith("/") or WINDOWS_DRIVE_RE.match(path):
+ return False
+ return all(part not in ("", ".", "..") for part in path.split("/"))
+
+
+def matches_any(path: str, patterns: list[str]) -> bool:
+ return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)
+
+
+def is_path_allowed(scope: str, path: str) -> bool:
+ path = normalize_path(path)
+ if not has_safe_segments(path) or matches_any(path, DENY_GLOBS):
+ return False
+ allow = ALLOWED_BY_SCOPE.get(scope)
+ if not allow:
+ return False
+ return matches_any(path, allow)
+
+
+def explain_scope(scope: str) -> str:
+ allow = ALLOWED_BY_SCOPE.get(scope, [])
+ return f"{scope} allows: " + ", ".join(allow)
diff --git a/tools/test_firmware.py b/tools/test_firmware.py
index 9bcb78f..df29abc 100644
--- a/tools/test_firmware.py
+++ b/tools/test_firmware.py
@@ -1,20 +1,29 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from tools.ci_runtime import pio_mode, resolve_target, resolved_pio_runner, run_platformio_step
+
+
+def test_firmware(target: str) -> int:
+ spec = resolve_target(target, expected_mode="test")
+ print(
+ f"Tests target '{spec.requested}' via PlatformIO env '{spec.env}' "
+ f"(mode={pio_mode()}, runner={resolved_pio_runner(spec, 'test')})"
+ )
+ rc = run_platformio_step(spec, "test")
+ if rc == 0:
+ print(f"Tests terminés pour {spec.requested}")
+ return rc
-def test_firmware(target):
- # Exemple minimal : tests pour chaque cible
- if target == 'esp':
- print('Tests ESP...')
- # Appel tests unitaires ESP
- elif target == 'stm':
- print('Tests STM...')
- # Appel tests unitaires STM
- elif target == 'linux':
- print('Tests Linux...')
- # Appel tests unitaires Linux
- else:
- print('Cible inconnue')
- sys.exit(1)
- print(f'Tests terminés pour {target}')
if __name__ == '__main__':
- test_firmware(sys.argv[1])
+ if len(sys.argv) != 2:
+ raise SystemExit("usage: test_firmware.py ")
+ raise SystemExit(test_firmware(sys.argv[1]))
diff --git a/tools/verify_evidence.py b/tools/verify_evidence.py
index a234efe..b8ebef1 100644
--- a/tools/verify_evidence.py
+++ b/tools/verify_evidence.py
@@ -1,19 +1,44 @@
-import os
-import sys
+#!/usr/bin/env python3
+from __future__ import annotations
-def verify_evidence(target):
- evidence_dir = f"docs/evidence/{target}"
- if os.path.exists(evidence_dir):
- files = os.listdir(evidence_dir)
- if files:
- print(f"Evidence pack trouvé pour {target} : {files}")
- return True
- else:
- print(f"Evidence pack vide pour {target}")
- return False
- else:
- print(f"Evidence pack absent pour {target}")
+import json
+import sys
+from pathlib import Path
+
+BOOTSTRAP_ROOT = Path(__file__).resolve().parents[1]
+if str(BOOTSTRAP_ROOT) not in sys.path:
+ sys.path.insert(0, str(BOOTSTRAP_ROOT))
+
+from tools.ci_runtime import ROOT, ensure_evidence_dir
+
+
+def verify_evidence(target: str) -> bool:
+ evidence_dir = ensure_evidence_dir(target)
+ summary_path = evidence_dir / "summary.json"
+ if not summary_path.exists():
+ print(f"Evidence pack absent pour {target}: {summary_path}")
return False
+ summary = json.loads(summary_path.read_text(encoding="utf-8"))
+ if summary.get("status") != "ok":
+ print(f"Evidence pack invalide pour {target}: {summary.get('missing', [])}")
+ return False
+
+ missing = []
+ for rel in summary.get("artifacts", []):
+ path = ROOT / rel
+ if not path.exists():
+ missing.append(rel)
+
+ if missing:
+ print(f"Artifacts manquants pour {target}: {missing}")
+ return False
+
+ print(f"Evidence pack trouvé pour {target}: {summary.get('artifacts', [])}")
+ return True
+
+
if __name__ == '__main__':
- verify_evidence(sys.argv[1])
+ if len(sys.argv) != 2:
+ raise SystemExit("usage: verify_evidence.py ")
+ raise SystemExit(0 if verify_evidence(sys.argv[1]) else 1)