|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""HC-124 demo: verify generated modules against the Hypercode resolved graph. |
| 3 | +
|
| 4 | +Two independent checks, stdlib only: |
| 5 | +
|
| 6 | + 1. Freshness — each module embeds the hash of the IR node it was generated |
| 7 | + from. Node hashes cover the stable resolved content (Merkle over the |
| 8 | + subtree), so a hash mismatch means the spec changed underneath the module |
| 9 | + and it must be regenerated. This is the invalidation signal that scopes |
| 10 | + regeneration: only stale modules are rebuilt. |
| 11 | +
|
| 12 | + 2. Contract conformance — values embedded in generated code are validated |
| 13 | + against the contracts[] echoed in the IR: type and bounds for present |
| 14 | + keys, presence for required contracted keys, and no CONFIG key may exist |
| 15 | + outside the resolved spec. Even a hand-edited "generated" file that |
| 16 | + smuggles in port: 99999 — or drops port entirely — is caught. |
| 17 | +
|
| 18 | +Each module owns its node's subtree up to the boundary of the next generated |
| 19 | +module (service.py owns /Service but not /Service/Logger.console, which |
| 20 | +logger.py owns). Node paths use selector identity (type[.class][#id]) — the |
| 21 | +same addressing as `hypercode diff`. |
| 22 | +
|
| 23 | +Exit codes: 0 = fresh & conformant, 1 = stale modules, 2 = contract violation. |
| 24 | +""" |
| 25 | +import argparse |
| 26 | +import ast |
| 27 | +import json |
| 28 | +import os |
| 29 | +import re |
| 30 | +import subprocess |
| 31 | +import sys |
| 32 | + |
| 33 | +HERE = os.path.dirname(os.path.abspath(__file__)) |
| 34 | +REPO = os.path.abspath(os.path.join(HERE, "..", "..")) |
| 35 | + |
| 36 | + |
| 37 | +def emit_ir(hc, hcs, ctx): |
| 38 | + binary = os.environ.get( |
| 39 | + "HYPERCODE_BIN", os.path.join(REPO, ".build", "debug", "hypercode")) |
| 40 | + cmd = [binary, "emit", hc, "--hcs", hcs, "--format", "json"] |
| 41 | + for pair in ctx: |
| 42 | + cmd += ["--ctx", pair] |
| 43 | + out = subprocess.run(cmd, capture_output=True, text=True, cwd=REPO) |
| 44 | + if out.returncode != 0: |
| 45 | + sys.exit(f"emit failed: {out.stderr.strip()}") |
| 46 | + return json.loads(out.stdout) |
| 47 | + |
| 48 | + |
| 49 | +def node_label(node): |
| 50 | + """Selector identity, same addressing as `hypercode diff`.""" |
| 51 | + label = node["type"] |
| 52 | + if "class" in node: |
| 53 | + label += f".{node['class']}" |
| 54 | + if "id" in node: |
| 55 | + label += f"#{node['id']}" |
| 56 | + return label |
| 57 | + |
| 58 | + |
| 59 | +def index_nodes(ir): |
| 60 | + """path -> node, depth-first; paths are /-joined selector identities.""" |
| 61 | + nodes = {} |
| 62 | + |
| 63 | + def walk(node, path): |
| 64 | + path = f"{path}/{node_label(node)}" |
| 65 | + if path in nodes: |
| 66 | + sys.exit(f"ambiguous node path '{path}' — give same-type siblings" |
| 67 | + " distinct classes or ids") |
| 68 | + nodes[path] = node |
| 69 | + for child in node["children"]: |
| 70 | + walk(child, path) |
| 71 | + |
| 72 | + for root in ir["nodes"]: |
| 73 | + walk(root, "") |
| 74 | + return nodes |
| 75 | + |
| 76 | + |
| 77 | +def owned_properties(node, path, claimed): |
| 78 | + """Resolved properties of the subtree this module owns: its node's |
| 79 | + subtree, stopping at children that are themselves generated modules.""" |
| 80 | + props = {} |
| 81 | + |
| 82 | + def walk(n, p): |
| 83 | + props.update(n["properties"]) |
| 84 | + for child in n["children"]: |
| 85 | + child_path = f"{p}/{node_label(child)}" |
| 86 | + if child_path not in claimed: |
| 87 | + walk(child, child_path) |
| 88 | + |
| 89 | + walk(node, path) |
| 90 | + return props |
| 91 | + |
| 92 | + |
| 93 | +def parse_module(path): |
| 94 | + """Extract the embedded node path, hash and CONFIG dict.""" |
| 95 | + text = open(path).read() |
| 96 | + node = re.search(r"^# node: (.+)$", text, re.M) |
| 97 | + digest = re.search(r"^# hash: ([0-9a-f]{64})$", text, re.M) |
| 98 | + config = re.search(r"^CONFIG = (\{.*?\n\}|\{\})$", text, re.M | re.S) |
| 99 | + if not (node and digest and config): |
| 100 | + sys.exit(f"{os.path.basename(path)}: missing generated-module markers") |
| 101 | + return node.group(1), digest.group(1), ast.literal_eval(config.group(1)) |
| 102 | + |
| 103 | + |
| 104 | +TYPE_CHECK = { |
| 105 | + "int": lambda v: isinstance(v, int) and not isinstance(v, bool), |
| 106 | + "float": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), |
| 107 | + "string": lambda v: isinstance(v, str), |
| 108 | + "bool": lambda v: isinstance(v, bool), |
| 109 | +} |
| 110 | + |
| 111 | + |
| 112 | +def check_contracts(config, props, module): |
| 113 | + """Validate embedded values against the contracts echoed in the IR.""" |
| 114 | + violations = [] |
| 115 | + # Drift: a CONFIG key the spec doesn't resolve is a hand-edit, not output. |
| 116 | + for key in config: |
| 117 | + if key not in props: |
| 118 | + violations.append( |
| 119 | + f"{module}: '{key}' is not a resolved property of this" |
| 120 | + " module's nodes — not in the spec") |
| 121 | + # Presence: a key under a required contract must be carried by CONFIG. |
| 122 | + for key, prop in sorted(props.items()): |
| 123 | + if key in config: |
| 124 | + continue |
| 125 | + for contract in prop.get("contracts", []): |
| 126 | + if contract.get("required"): |
| 127 | + violations.append( |
| 128 | + f"{module}: required '{key}' missing from CONFIG" |
| 129 | + f" (contract '{contract['selector']}')") |
| 130 | + break |
| 131 | + for key, value in config.items(): |
| 132 | + for contract in props.get(key, {}).get("contracts", []): |
| 133 | + sel = contract["selector"] |
| 134 | + if not TYPE_CHECK[contract["type"]](value): |
| 135 | + violations.append( |
| 136 | + f"{module}: '{key}' = {value!r} is not {contract['type']}" |
| 137 | + f" (contract '{sel}')") |
| 138 | + continue |
| 139 | + if isinstance(value, (int, float)) and not isinstance(value, bool): |
| 140 | + if "min" in contract and value < contract["min"]: |
| 141 | + violations.append( |
| 142 | + f"{module}: '{key}' = {value} below {contract['min']}" |
| 143 | + f" (contract '{sel}')") |
| 144 | + if "max" in contract and value > contract["max"]: |
| 145 | + violations.append( |
| 146 | + f"{module}: '{key}' = {value} exceeds {contract['max']}" |
| 147 | + f" (contract '{sel}')") |
| 148 | + return violations |
| 149 | + |
| 150 | + |
| 151 | +def main(): |
| 152 | + parser = argparse.ArgumentParser(description=__doc__) |
| 153 | + parser.add_argument("--hc", default="Examples/service.hc") |
| 154 | + parser.add_argument("--hcs", default="Examples/service.hcs") |
| 155 | + parser.add_argument("--ctx", action="append", default=None, |
| 156 | + help="key=value (default: env=production)") |
| 157 | + parser.add_argument("--list-stale", action="store_true", |
| 158 | + help="print stale module filenames only") |
| 159 | + args = parser.parse_args() |
| 160 | + |
| 161 | + ir = emit_ir(args.hc, args.hcs, args.ctx or ["env=production"]) |
| 162 | + nodes = index_nodes(ir) |
| 163 | + |
| 164 | + gen_dir = os.path.join(HERE, "generated") |
| 165 | + modules = [ |
| 166 | + (name, *parse_module(os.path.join(gen_dir, name))) |
| 167 | + for name in sorted(os.listdir(gen_dir)) if name.endswith(".py") |
| 168 | + ] |
| 169 | + # Module boundaries: a node generated as its own module is not part of |
| 170 | + # its parent module's owned subtree. |
| 171 | + claimed = {node_path for _, node_path, _, _ in modules} |
| 172 | + |
| 173 | + stale, violations = [], [] |
| 174 | + for name, node_path, embedded, config in modules: |
| 175 | + node = nodes.get(node_path) |
| 176 | + if node is None: |
| 177 | + sys.exit(f"{name}: node '{node_path}' no longer exists in the IR") |
| 178 | + fresh = node["hash"] == embedded |
| 179 | + if not fresh: |
| 180 | + stale.append(name) |
| 181 | + owned = owned_properties(node, node_path, claimed - {node_path}) |
| 182 | + violations += check_contracts(config, owned, name) |
| 183 | + if not args.list_stale: |
| 184 | + status = "FRESH" if fresh else "STALE" |
| 185 | + print(f" {status} {name:16} {node_path}") |
| 186 | + |
| 187 | + if args.list_stale: |
| 188 | + print("\n".join(stale)) |
| 189 | + return 1 if stale else 0 |
| 190 | + |
| 191 | + if violations: |
| 192 | + print("\ncontract violations in generated artifacts:") |
| 193 | + for v in violations: |
| 194 | + print(f" error[HC2104-gen] {v}") |
| 195 | + return 2 |
| 196 | + if stale: |
| 197 | + print(f"\n{len(stale)} module(s) stale — regenerate with generate.sh") |
| 198 | + return 1 |
| 199 | + print("\nall modules fresh and contract-conformant") |
| 200 | + return 0 |
| 201 | + |
| 202 | + |
| 203 | +if __name__ == "__main__": |
| 204 | + sys.exit(main()) |
0 commit comments