"""ACDL Contract Resolver — resolve a contract to a Target Stack IR instance. ARCHITECTURE.md §12.8: the contract declares intent in IR-typed terms; the resolver resolves the contract to a target stack (list of L1 instances + inputs + relationships); the adapter compiles the target stack to a plan. Steps: 1. Load the contract (YAML -> dict). 2. Validate the contract against schemas/contract.schema.json. 3. Look up the L2 in modules-ir/registry.json. 4. Load the L2's composition.json (the thin-composition tree). 5. Map the contract's inputs through the composition's wires to the child L1s' inputs. Two wire kinds: - passthrough: {target, input} (or an array of the same) -> the concrete contract value. - child->child: {target, input, source:"child:."} -> a "ref:." string (value known at apply time only). A wire value may be a single object or an array of objects (for contract inputs that fan out to multiple children); both forms are iterated. 6. Emit an IR instance {version, stack:{name, kind:l2, depth}, resources:[], relationships:[...]}. Multi-resource L1s (interface.json has a `resources` array) expand into one IR resource per entry, id `-` where type_suffix is the last IR-type segment with underscores stripped; single-resource L1s keep the child id verbatim. 7. Validate the IR instance against schemas/ir.schema.json. CLI: contract_resolver.py """ import json import os import sys import yaml import jsonschema REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def _load_json(path): with open(path, "r") as fh: return json.load(fh) def _iter_wire_targets(wire_value): """Yield each target-spec from a wire value (single object or array).""" if isinstance(wire_value, list): for spec in wire_value: yield spec elif isinstance(wire_value, dict): yield wire_value def _type_suffix(ir_type): """Last segment of an IR type, underscores stripped (e.g. aws:ec2:vpc -> vpc, aws:elbv2:targetgroup -> targetgroup, aws:ecs:task_definition -> taskdefinition).""" return ir_type.rsplit(":", 1)[-1].replace("_", "") def _resolve_child_ref(source, child_id, l1_iface, child_ir_ids): """Resolve a "child:." source to "ref:.". The ir_resource_id is the producing child's sub-resource that declares the output. For single-resource L1s that is the child id; for multi-resource L1s the L1's `resources` array is scanned for which sub-resource declares the output (exact match, then a singular->plural fallback so e.g. `subnet_ids` matches a per-resource `subnet_id`). The ref's output name is the per-resource output name when matched that way, else the source output name verbatim. """ prefix = "child:" if not source.startswith(prefix): raise ValueError(f"unsupported wire source {source!r}") body = source[len(prefix):] src_child_id, src_output = body.split(".", 1) if src_child_id != child_id: # Cross-child reference: look up the producing child's first IR # resource id (the child->child wiring table is keyed by child id # by the caller; this branch is unused for v1.2's wires but kept # for completeness). ir_resource_id = child_ir_ids.get(src_child_id, src_child_id) return f"ref:{ir_resource_id}.{src_output}" # Same-child reference: find the producing sub-resource. resources = l1_iface.get("resources") if not resources: return f"ref:{child_id}.{src_output}" for idx, sub in enumerate(resources): sub_outputs = sub.get("outputs", []) if src_output in sub_outputs: ir_id = child_ir_ids[child_id][idx] return f"ref:{ir_id}.{src_output}" # Singular->plural fallback (subnet_ids -> subnet_id). singular = src_output[:-1] if src_output.endswith("s") else src_output for idx, sub in enumerate(resources): sub_outputs = sub.get("outputs", []) if singular in sub_outputs: ir_id = child_ir_ids[child_id][idx] return f"ref:{ir_id}.{singular}" # No per-resource match: point at the first sub-resource, keep the # source output name verbatim. ir_id = child_ir_ids[child_id][0] return f"ref:{ir_id}.{src_output}" def resolve(contract_path, repo_root=None): """Resolve a contract YAML to an IR instance dict.""" rr = repo_root or REPO_ROOT # 1. Load the contract YAML. with open(contract_path, "r") as fh: contract = yaml.safe_load(fh) # 2. Validate the contract against the contract schema. contract_schema = _load_json(os.path.join(rr, "schemas/contract.schema.json")) jsonschema.validate(contract, contract_schema) # 3. Look up the L2 in the registry. stack_name = contract["stack"] registry = _load_json(os.path.join(rr, "modules-ir/registry.json")) if stack_name not in registry: raise ValueError(f"stack {stack_name!r} not in registry") versions = registry[stack_name] # Pick the highest 1.x.x (spike: just take the first non-deprecated). entry = next(v for v in versions.values() if not v.get("deprecated", False)) # 4. Load the L2's composition.json. composition_key = entry.get("composition") or entry.get("interface") composition = _load_json(os.path.join(rr, composition_key)) # 5. Map the contract's inputs through the wires to the child L1s' inputs. wires = composition.get("wires", {}) contract_inputs = contract.get("inputs", {}) children = composition.get("children", []) # Pre-load every child's L1 interface + compute IR resource ids. child_ifaces = {} child_ir_ids = {} for child in children: child_id = child["id"] child_module = child["module"] l1_name, l1_version = child_module.split("@", 1) l1_entry = registry.get(l1_name, {}).get(l1_version) if not l1_entry: raise ValueError(f"L1 {child_module!r} not in registry") l1_iface = _load_json(os.path.join(rr, l1_entry["interface"])) child_ifaces[child_id] = l1_iface sub_resources = l1_iface.get("resources") if sub_resources: child_ir_ids[child_id] = [ f"{child_id}-{_type_suffix(sub['type'])}" for sub in sub_resources ] else: child_ir_ids[child_id] = [child_id] # Build each child's mapped inputs (concrete values + ref strings). child_inputs_map = {child["id"]: {} for child in children} for wire_name, wire_value in wires.items(): for spec in _iter_wire_targets(wire_value): target = spec.get("target") if target not in child_inputs_map: continue input_name = spec["input"] source = spec.get("source") if source: # Child->child reference: emit a ref string. src_child_id = source[len("child:"):].split(".", 1)[0] child_inputs_map[target][input_name] = _resolve_child_ref( source, src_child_id, child_ifaces[src_child_id], child_ir_ids ) else: # Contract->child passthrough. if wire_name in contract_inputs: child_inputs_map[target][input_name] = contract_inputs[wire_name] # 6. Emit the IR instance. resources = [] relationships = [] for child in children: child_id = child["id"] child_module = child["module"] l1_iface = child_ifaces[child_id] l1_outputs = l1_iface.get("outputs", {}) child_inputs = child_inputs_map[child_id] sub_resources = l1_iface.get("resources") ir_ids = child_ir_ids[child_id] if sub_resources: for idx, sub in enumerate(sub_resources): ir_id = ir_ids[idx] sub_in_names = sub.get("inputs", []) sub_out_names = sub.get("outputs", []) sub_inputs = { n: child_inputs[n] for n in sub_in_names if n in child_inputs } sub_outputs = { n: l1_outputs[n] for n in sub_out_names if n in l1_outputs } resources.append({ "id": ir_id, "type": sub["type"], "module": child_module, "inputs": sub_inputs, "outputs": sub_outputs, }) relationships.append({"from": "root", "to": ir_id, "kind": "parent"}) else: resources.append({ "id": child_id, "type": l1_iface["type"], "module": child_module, "inputs": child_inputs, "outputs": l1_outputs, }) relationships.append({"from": "root", "to": child_id, "kind": "parent"}) ir_instance = { "version": "1.0.0", "stack": { "name": composition["name"], "kind": composition["kind"], "depth": composition["depth"], }, "resources": resources, "relationships": relationships, } # 7. Validate the IR instance against the IR schema. ir_schema = _load_json(os.path.join(rr, "schemas/ir.schema.json")) jsonschema.validate(ir_instance, ir_schema) return ir_instance if __name__ == "__main__": if len(sys.argv) != 3: print("usage: contract_resolver.py ", file=sys.stderr) sys.exit(2) ir = resolve(sys.argv[1]) with open(sys.argv[2], "w") as fh: json.dump(ir, fh, indent=2) print(f"resolver: emitted IR to {sys.argv[2]}", file=sys.stderr)