"""Overlay / custom-test / install-steps discovery + precedence (Phase 1d/1e, DG4/DG5 + HC2/HC3). The generic is the default floor for each lifecycle op and, per Phase 1e HC3, runs ADDITIVELY alongside a recipe overlay by default (the orchestrator owns the op; both assertion sets evaluate the shared post-op state). Discovery here only locates the candidate assertion files + the install-steps hook; the orchestrator decides additive-vs-skip. Sources, in precedence order (machine-docs/DECISIONS.md): lifecycle op (install/upgrade/backup/restore) — the OVERLAY assertion file, if any: repo-local tests/test_.py (upstream-authoritative, wins same-name collisions) > cc-ci tests//test_.py (the generic tests/_generic/test_.py is the always-present floor, run separately by default) custom (non-lifecycle) test_*.py — ALL run, additively, from BOTH locations (opt-in). install-steps hook — install_steps.sh: repo-local > cc-ci, or none. Repo-local = the recipe repo's own tests/ dir, snapshotted after fetch (it survives abra re-checking-out the recipe to a version tag — see the run orchestrator). It is PR-author-controlled code that runs on the CI host with /run/secrets/* present, so per Phase 1e HC2 it is **default-deny**: the repo-local source is consulted ONLY when the recipe is on the cc-ci approval allowlist (`tests/repo-local-approved.txt`). Otherwise precedence is cc-ci > generic only. """ from __future__ import annotations import glob import os LIFECYCLE_OPS = ("install", "upgrade", "backup", "restore") ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) GENERIC_DIR = os.path.join(ROOT, "tests", "_generic") APPROVED_FILE = os.path.join(ROOT, "tests", "repo-local-approved.txt") def cc_ci_dir(recipe: str) -> str: return os.path.join(ROOT, "tests", recipe) def approved_file_path() -> str: """Location of the repo-local approval allowlist. Overridable via CCCI_REPO_LOCAL_APPROVED_FILE (used by tests + by the Adversary to demonstrate approved-vs-not without editing the checked-in file). Default: the git-tracked tests/repo-local-approved.txt.""" return os.environ.get("CCCI_REPO_LOCAL_APPROVED_FILE", APPROVED_FILE) def approved_recipes() -> set[str]: """Recipes whose repo-local (PR-authored) code is trusted to execute (HC2). One name per line in the allowlist; `#` comments + blank lines ignored. Missing file ⇒ empty set ⇒ default-deny.""" names: set[str] = set() try: with open(approved_file_path()) as fh: for raw in fh: line = raw.split("#", 1)[0].strip() if line: names.add(line) except OSError: pass return names def repo_local_approved(recipe: str) -> bool: """True iff `recipe` is on the cc-ci repo-local approval allowlist (default-deny, HC2).""" return recipe in approved_recipes() def _gated(recipe: str, repo_local_dir: str | None) -> str | None: """The repo-local dir to actually consult: the given dir if the recipe is approved, else None (default-deny). Centralizes the HC2 gate so every discovery function honors it identically.""" return repo_local_dir if (repo_local_dir and repo_local_approved(recipe)) else None def resolve_overlay_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str] | None: """Return (source, path) for the OVERLAY assertion file for `op` (repo-local > cc-ci), or None if the recipe ships no overlay for it. The generic floor is handled separately by the orchestrator (HC3 additive). Repo-local is consulted only for allowlist-approved recipes (HC2).""" fname = f"test_{op}.py" rl = _gated(recipe, repo_local_dir) if rl: p = os.path.join(rl, fname) if os.path.isfile(p): return ("repo-local", p) p = os.path.join(cc_ci_dir(recipe), fname) if os.path.isfile(p): return ("cc-ci", p) return None def generic_op(op: str) -> tuple[str, str]: """The always-present generic assertion file for `op` (the floor, HC3).""" return ("generic", os.path.join(GENERIC_DIR, f"test_{op}.py")) def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str]: """Back-compat single-file resolver (override semantics): overlay if present, else generic. Phase-1e orchestration uses resolve_overlay_op + generic_op (additive); this remains for unit tests and any caller wanting the legacy "one file wins" view. HC2 gate still applies.""" return resolve_overlay_op(recipe, op, repo_local_dir) or generic_op(op) def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]: """All non-lifecycle test_*.py from cc-ci's tests// and (if approved) the recipe's repo-local tests/. Discovered locations (Phase 2 §4.1): - the top-level dir tests//test_*.py (legacy + cross-cutting) - functional/ tests//functional/test_*.py (parity ports + recipe-specific) - playwright/ tests//playwright/test_*.py (UI flows P6) Files named `test_.py` (lifecycle ops) are excluded from this list — the orchestrator runs those in their lifecycle tier, not the custom one. Repo-local is consulted only for allowlist-approved recipes (HC2).""" lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS} subdirs = ("functional", "playwright") found: list[tuple[str, str]] = [] for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))): if not d or not os.path.isdir(d): continue # top-level (legacy / cross-cutting tests not under functional/playwright) for p in sorted(glob.glob(os.path.join(d, "test_*.py"))): if os.path.basename(p) not in lifecycle_names: found.append((source, p)) # functional/ and playwright/ subdirs (Phase 2 §4.1) for sub in subdirs: for p in sorted(glob.glob(os.path.join(d, sub, "test_*.py"))): # Phase-2 layout: lifecycle ops never live under functional/playwright, but be # explicit so a misfiled file doesn't silently get double-run. if os.path.basename(p) not in lifecycle_names: found.append((source, p)) return found def install_steps(recipe: str, repo_local_dir: str | None) -> tuple[str, str] | None: """The custom install-steps hook (install_steps.sh) for a recipe, or None. repo-local > cc-ci. Repo-local is consulted only for allowlist-approved recipes (HC2).""" rl = _gated(recipe, repo_local_dir) if rl: p = os.path.join(rl, "install_steps.sh") if os.path.isfile(p): return ("repo-local", p) p = os.path.join(cc_ci_dir(recipe), "install_steps.sh") if os.path.isfile(p): return ("cc-ci", p) return None def pre_op_hook(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str] | None: """The pre-op seed hook for `op`: the path to a recipe `ops.py` module that defines a `pre_(domain, meta)` callable, or None. cc-ci's tests//ops.py wins; the repo-local ops.py is consulted only for allowlist-approved recipes (HC2). The orchestrator imports the module and calls pre_ BEFORE performing the op (HC3 op/assertion split — overlays seed pre-op state here, then assert post-op in test_.py).""" fn = f"pre_{op}" for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", _gated(recipe, repo_local_dir))): if not d: continue p = os.path.join(d, "ops.py") if os.path.isfile(p) and _module_defines(p, fn): return (source, p) return None def _module_defines(path: str, name: str) -> bool: """Cheap source scan for a top-level `def (` — avoids importing the module just to check.""" try: with open(path) as fh: src = fh.read() except OSError: return False return f"def {name}(" in src