-
Notifications
You must be signed in to change notification settings - Fork 1
feat: HC-124 — end-to-end AI codegen demo #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
f9d7a93
feat: HC-124 — end-to-end AI codegen demo (Examples/codegen-demo)
SoundBlaster a317096
chore: drop committed Python bytecode, ignore __pycache__
SoundBlaster 3b7e5da
Merge branch 'feat/hc-111-value-validation' into feat/hc-124-codegen-…
SoundBlaster ec1abbb
fix: address #24 review — required/drift conformance, selector-identi…
SoundBlaster File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,3 +3,5 @@ | |
| .DS_Store | ||
| *.xcodeproj | ||
| site/ | ||
| __pycache__/ | ||
| *.pyc | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| # HC-124 — End-to-end AI codegen demo | ||
|
|
||
| The runnable version of the pipeline from [RFC §9.7](../../RFC/Hypercode.md) | ||
| and [DOCS/Usage.md §5](../../DOCS/Usage.md): the spec is the durable artifact, | ||
| code is regenerated output, and the resolved graph sits between them. | ||
|
|
||
| ``` | ||
| Examples/service.{hc,hcs} ──▶ IR v2 ──▶ generated/*.py (one module per node) | ||
| │ │ │ | ||
| 1-line edit node hashes check.py validates artifacts | ||
| (review this!) (what changed?) against the same contracts | ||
| ``` | ||
|
|
||
| Every module in [`generated/`](generated/) was produced by Claude from the | ||
| production-context IR of [`Examples/service.{hc,hcs}`](../service.hc). Each | ||
| embeds the **hash of its source node** and every value carries its | ||
| **provenance** as a comment: | ||
|
|
||
| ```python | ||
| # node: /Service/APIServer | ||
| # hash: 7da0acd4b1617ed3… | ||
| CONFIG = { | ||
| "port": 8080, # APIServer > Listen @ Examples/service.hcs:26 | ||
| } | ||
| ``` | ||
|
|
||
| ## 1. Verify: artifacts match the spec | ||
|
|
||
| ```console | ||
| $ python3 check.py | ||
| FRESH api_server.py /Service/APIServer | ||
| FRESH database.py /Service/Database#main-db | ||
| FRESH logger.py /Service/Logger.console | ||
| FRESH service.py /Service | ||
|
|
||
| all modules fresh and contract-conformant | ||
| ``` | ||
|
|
||
| `check.py` re-emits the IR and does two things: compares each module's | ||
| embedded node hash against the current one (**freshness**), and validates the | ||
| values embedded in the generated code against the `contracts[]` echoed in the | ||
| IR (**conformance**): type and bounds for present keys, presence for required | ||
| contracted keys, and no CONFIG key outside the resolved spec. Node paths use | ||
| selector identity (`type[.class][#id]`) — the same addressing as | ||
| `hypercode diff`. CI runs this on every push. | ||
|
|
||
| ## 2. Scoped regeneration: a one-line spec edit | ||
|
|
||
| ```console | ||
| $ sed 's/port: 8080/port: 9090/' ../service.hcs > /tmp/edited.hcs | ||
| $ python3 check.py --hcs /tmp/edited.hcs | ||
| STALE api_server.py /Service/APIServer | ||
| FRESH database.py /Service/Database#main-db | ||
| FRESH logger.py /Service/Logger.console | ||
| STALE service.py /Service | ||
|
|
||
| 2 module(s) stale — regenerate with generate.sh | ||
| ``` | ||
|
|
||
| The port lives on the `Listen` node, so exactly `api_server.py` is stale — | ||
| plus the root wiring, because a node's hash is a Merkle hash over its subtree. | ||
| `logger.py` and `database.py` are untouched and **not** regenerated. This is | ||
| the review-compression loop: a human reviews the one-line spec diff; the | ||
| machine knows precisely which artifacts it invalidates. | ||
|
|
||
| ## 3. Guardrails on both sides of generation | ||
|
|
||
| **Before** generation, the spec itself is gated — a bad edit never reaches | ||
| the generator: | ||
|
|
||
| ```console | ||
| $ hypercode validate bad.hc --hcs bad.hcs --ctx env=production | ||
| bad.hcs:1:1: error[HC2104]: contract violation for 'port': 99999 exceeds upper bound 65535.0 … | ||
| ``` | ||
|
|
||
| **After** generation, the artifacts are checked against the same contracts. | ||
| Hand-edit `generated/api_server.py` to `"port": 99999` and: | ||
|
|
||
| ```console | ||
| $ python3 check.py | ||
| contract violations in generated artifacts: | ||
| error[HC2104-gen] api_server.py: 'port' = 99999 exceeds 65535.0 (contract 'APIServer > Listen') | ||
| $ echo $? | ||
| 2 | ||
| ``` | ||
|
|
||
| The contract written once in the `.hcs` governs the spec, the resolved graph, | ||
| and the generated code. | ||
|
|
||
| ## 4. Regenerate with Claude | ||
|
|
||
| ```console | ||
| $ ./generate.sh # needs the `claude` CLI | ||
| regenerating api_server.py from node /Service/APIServer … | ||
| ``` | ||
|
|
||
| `check.py --list-stale` scopes the work; `generate.sh` feeds Claude the IR | ||
| subtree of each stale node (the source of truth) plus the module conventions, | ||
| then re-runs the checks. No stale module — no LLM call. | ||
|
|
||
| ## 5. It actually runs | ||
|
|
||
| ```console | ||
| $ python3 generated/service.py | ||
| ('0.0.0.0', 8080) | ||
| ``` | ||
|
|
||
| ## Files | ||
|
|
||
| | File | Role | | ||
| |---|---| | ||
| | `check.py` | freshness (node hashes) + contract conformance of artifacts | | ||
| | `generate.sh` | scoped regeneration of stale modules via `claude -p` | | ||
| | `generated/*.py` | the generated service — one module per `.hc` node | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| #!/usr/bin/env python3 | ||
| """HC-124 demo: verify generated modules against the Hypercode resolved graph. | ||
|
|
||
| Two independent checks, stdlib only: | ||
|
|
||
| 1. Freshness — each module embeds the hash of the IR node it was generated | ||
| from. Node hashes cover the stable resolved content (Merkle over the | ||
| subtree), so a hash mismatch means the spec changed underneath the module | ||
| and it must be regenerated. This is the invalidation signal that scopes | ||
| regeneration: only stale modules are rebuilt. | ||
|
|
||
| 2. Contract conformance — values embedded in generated code are validated | ||
| against the contracts[] echoed in the IR: type and bounds for present | ||
| keys, presence for required contracted keys, and no CONFIG key may exist | ||
| outside the resolved spec. Even a hand-edited "generated" file that | ||
| smuggles in port: 99999 — or drops port entirely — is caught. | ||
|
|
||
| Each module owns its node's subtree up to the boundary of the next generated | ||
| module (service.py owns /Service but not /Service/Logger.console, which | ||
| logger.py owns). Node paths use selector identity (type[.class][#id]) — the | ||
| same addressing as `hypercode diff`. | ||
|
|
||
| Exit codes: 0 = fresh & conformant, 1 = stale modules, 2 = contract violation. | ||
| """ | ||
| import argparse | ||
| import ast | ||
| import json | ||
| import os | ||
| import re | ||
| import subprocess | ||
| import sys | ||
|
|
||
| HERE = os.path.dirname(os.path.abspath(__file__)) | ||
| REPO = os.path.abspath(os.path.join(HERE, "..", "..")) | ||
|
|
||
|
|
||
| def emit_ir(hc, hcs, ctx): | ||
| binary = os.environ.get( | ||
| "HYPERCODE_BIN", os.path.join(REPO, ".build", "debug", "hypercode")) | ||
| cmd = [binary, "emit", hc, "--hcs", hcs, "--format", "json"] | ||
| for pair in ctx: | ||
| cmd += ["--ctx", pair] | ||
| out = subprocess.run(cmd, capture_output=True, text=True, cwd=REPO) | ||
| if out.returncode != 0: | ||
| sys.exit(f"emit failed: {out.stderr.strip()}") | ||
| return json.loads(out.stdout) | ||
|
|
||
|
|
||
| def node_label(node): | ||
| """Selector identity, same addressing as `hypercode diff`.""" | ||
| label = node["type"] | ||
| if "class" in node: | ||
| label += f".{node['class']}" | ||
| if "id" in node: | ||
| label += f"#{node['id']}" | ||
| return label | ||
|
|
||
|
|
||
| def index_nodes(ir): | ||
| """path -> node, depth-first; paths are /-joined selector identities.""" | ||
| nodes = {} | ||
|
|
||
| def walk(node, path): | ||
| path = f"{path}/{node_label(node)}" | ||
| if path in nodes: | ||
| sys.exit(f"ambiguous node path '{path}' — give same-type siblings" | ||
| " distinct classes or ids") | ||
| nodes[path] = node | ||
| for child in node["children"]: | ||
| walk(child, path) | ||
|
|
||
| for root in ir["nodes"]: | ||
| walk(root, "") | ||
| return nodes | ||
|
|
||
|
|
||
| def owned_properties(node, path, claimed): | ||
| """Resolved properties of the subtree this module owns: its node's | ||
| subtree, stopping at children that are themselves generated modules.""" | ||
| props = {} | ||
|
|
||
| def walk(n, p): | ||
| props.update(n["properties"]) | ||
| for child in n["children"]: | ||
| child_path = f"{p}/{node_label(child)}" | ||
| if child_path not in claimed: | ||
| walk(child, child_path) | ||
|
|
||
| walk(node, path) | ||
| return props | ||
|
|
||
|
|
||
| def parse_module(path): | ||
| """Extract the embedded node path, hash and CONFIG dict.""" | ||
| text = open(path).read() | ||
| node = re.search(r"^# node: (.+)$", text, re.M) | ||
| digest = re.search(r"^# hash: ([0-9a-f]{64})$", text, re.M) | ||
| config = re.search(r"^CONFIG = (\{.*?\n\}|\{\})$", text, re.M | re.S) | ||
| if not (node and digest and config): | ||
| sys.exit(f"{os.path.basename(path)}: missing generated-module markers") | ||
| return node.group(1), digest.group(1), ast.literal_eval(config.group(1)) | ||
|
|
||
|
|
||
| TYPE_CHECK = { | ||
| "int": lambda v: isinstance(v, int) and not isinstance(v, bool), | ||
| "float": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), | ||
| "string": lambda v: isinstance(v, str), | ||
| "bool": lambda v: isinstance(v, bool), | ||
| } | ||
|
|
||
|
|
||
| def check_contracts(config, props, module): | ||
| """Validate embedded values against the contracts echoed in the IR.""" | ||
| violations = [] | ||
| # Drift: a CONFIG key the spec doesn't resolve is a hand-edit, not output. | ||
| for key in config: | ||
| if key not in props: | ||
| violations.append( | ||
| f"{module}: '{key}' is not a resolved property of this" | ||
| " module's nodes — not in the spec") | ||
| # Presence: a key under a required contract must be carried by CONFIG. | ||
| for key, prop in sorted(props.items()): | ||
| if key in config: | ||
| continue | ||
| for contract in prop.get("contracts", []): | ||
| if contract.get("required"): | ||
| violations.append( | ||
| f"{module}: required '{key}' missing from CONFIG" | ||
| f" (contract '{contract['selector']}')") | ||
| break | ||
| for key, value in config.items(): | ||
| for contract in props.get(key, {}).get("contracts", []): | ||
|
SoundBlaster marked this conversation as resolved.
|
||
| sel = contract["selector"] | ||
| if not TYPE_CHECK[contract["type"]](value): | ||
| violations.append( | ||
| f"{module}: '{key}' = {value!r} is not {contract['type']}" | ||
| f" (contract '{sel}')") | ||
| continue | ||
| if isinstance(value, (int, float)) and not isinstance(value, bool): | ||
| if "min" in contract and value < contract["min"]: | ||
| violations.append( | ||
| f"{module}: '{key}' = {value} below {contract['min']}" | ||
| f" (contract '{sel}')") | ||
| if "max" in contract and value > contract["max"]: | ||
| violations.append( | ||
| f"{module}: '{key}' = {value} exceeds {contract['max']}" | ||
| f" (contract '{sel}')") | ||
| return violations | ||
|
SoundBlaster marked this conversation as resolved.
|
||
|
|
||
|
|
||
| def main(): | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--hc", default="Examples/service.hc") | ||
| parser.add_argument("--hcs", default="Examples/service.hcs") | ||
| parser.add_argument("--ctx", action="append", default=None, | ||
| help="key=value (default: env=production)") | ||
| parser.add_argument("--list-stale", action="store_true", | ||
| help="print stale module filenames only") | ||
| args = parser.parse_args() | ||
|
|
||
| ir = emit_ir(args.hc, args.hcs, args.ctx or ["env=production"]) | ||
| nodes = index_nodes(ir) | ||
|
|
||
| gen_dir = os.path.join(HERE, "generated") | ||
| modules = [ | ||
| (name, *parse_module(os.path.join(gen_dir, name))) | ||
| for name in sorted(os.listdir(gen_dir)) if name.endswith(".py") | ||
| ] | ||
| # Module boundaries: a node generated as its own module is not part of | ||
| # its parent module's owned subtree. | ||
| claimed = {node_path for _, node_path, _, _ in modules} | ||
|
|
||
| stale, violations = [], [] | ||
| for name, node_path, embedded, config in modules: | ||
| node = nodes.get(node_path) | ||
| if node is None: | ||
| sys.exit(f"{name}: node '{node_path}' no longer exists in the IR") | ||
| fresh = node["hash"] == embedded | ||
| if not fresh: | ||
| stale.append(name) | ||
| owned = owned_properties(node, node_path, claimed - {node_path}) | ||
| violations += check_contracts(config, owned, name) | ||
| if not args.list_stale: | ||
| status = "FRESH" if fresh else "STALE" | ||
| print(f" {status} {name:16} {node_path}") | ||
|
|
||
| if args.list_stale: | ||
| print("\n".join(stale)) | ||
| return 1 if stale else 0 | ||
|
|
||
| if violations: | ||
| print("\ncontract violations in generated artifacts:") | ||
| for v in violations: | ||
| print(f" error[HC2104-gen] {v}") | ||
| return 2 | ||
| if stale: | ||
| print(f"\n{len(stale)} module(s) stale — regenerate with generate.sh") | ||
| return 1 | ||
| print("\nall modules fresh and contract-conformant") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.