From ed3651922385f0974160a5549e9bd6aa5a46b346 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Sat, 1 Aug 2026 13:14:31 +0000 Subject: [PATCH] =?UTF-8?q?verify(P14):=20schema-driven-outputs-and-cache?= =?UTF-8?q?=20=E2=80=94=204-layer=20verify=20PASS=20+=20ship?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERIFY: structural — schema-driven outputs + cache; behavioral — 47 tests + CI PASS; quality — mid-milestone checkpoint clean. ---ci--- project: acdl phase: 14 milestone: v1.16 status: complete phase_role: execution requirements: covered: [REQ-178] partial: [] ---/ci--- --- core/contract_resolver.py | 19 +++++++++++++++++-- core/output_publisher.py | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/core/contract_resolver.py b/core/contract_resolver.py index a53105d..ff7d7dd 100644 --- a/core/contract_resolver.py +++ b/core/contract_resolver.py @@ -64,6 +64,21 @@ def _load_json(path): return json.load(fh) +# P14 (REQ-178): cache loaded JSON schemas so resolve() doesn't re-read +# from disk on every call. +_SCHEMA_CACHE: dict = {} + + +def _load_schema(path): + """Load a JSON schema with caching (P14, REQ-178).""" + cached = _SCHEMA_CACHE.get(path) + if cached is not None: + return cached + schema = _load_json(path) + _SCHEMA_CACHE[path] = schema + return schema + + def _load_yaml(path): with open(path, "r") as fh: return yaml.safe_load(fh) @@ -468,7 +483,7 @@ def resolve(contract_path, repo_root=None, environment_override=None): contract["environment"] = environment_override # Load schemas - contract_schema = _load_json(os.path.join(repo_root, "schemas", "contract.schema.json")) + contract_schema = _load_schema(os.path.join(repo_root, "schemas", "contract.schema.json")) # Validate contract against schema jsonschema.validate(contract, contract_schema) @@ -588,7 +603,7 @@ def resolve(contract_path, repo_root=None, environment_override=None): stack_instance["outputs"] = merged_outputs # Validate against stack schema - stack_schema = _load_json(os.path.join(repo_root, "schemas", "stack.schema.json")) + stack_schema = _load_schema(os.path.join(repo_root, "schemas", "stack.schema.json")) jsonschema.validate(stack_instance, stack_schema) return stack_instance diff --git a/core/output_publisher.py b/core/output_publisher.py index 9543e47..3e1815a 100644 --- a/core/output_publisher.py +++ b/core/output_publisher.py @@ -38,8 +38,10 @@ from core import env as _envhelper SSM_PREFIX = "/nova" KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID" -# Outputs that are safe to display in a PR comment (no secrets). -SAFE_OUTPUT_NAMES = { +# P14 (REQ-178): SAFE_OUTPUT_NAMES is schema-driven (derived from +# modules/l1/*/interface.json outputs that don't have sensitive:true). +# Falls back to the hardcoded set if the interfaces can't be read. +_HARDCODED_SAFE_OUTPUTS = { "distribution_domain_name", "bucket_arn", "bucket_name", @@ -59,6 +61,37 @@ SAFE_OUTPUT_NAMES = { } +def _load_safe_output_names(): + """Derive the safe-output allowlist from interface.json outputs. + + P14 (REQ-178): scan modules/l1/*/interface.json; an output is safe if + its spec does not set sensitive:true. Falls back to the hardcoded set + if no interfaces are readable. + """ + import json + from pathlib import Path + root = Path(__file__).resolve().parent.parent + safe = set() + try: + for iface in (root / "modules" / "l1").glob("*/interface.json"): + d = json.loads(iface.read_text()) + outs = d.get("outputs", {}) + if isinstance(outs, dict): + for name, spec in outs.items(): + if not (isinstance(spec, dict) and spec.get("sensitive")): + safe.add(name) + elif isinstance(outs, list): + for out in outs: + if isinstance(out, dict) and not out.get("sensitive"): + safe.add(out.get("name", "")) + except (OSError, ValueError): + pass + return safe or _HARDCODED_SAFE_OUTPUTS + + +SAFE_OUTPUT_NAMES = _load_safe_output_names() + + def _ssm_client(): if boto3 is None: raise RuntimeError("boto3 is required for SSM publishing")