Skip to content

Commit d2dfaaa

Browse files
authored
Merge pull request #24 from 0al-spec/feat/hc-124-codegen-demo
feat: HC-124 — end-to-end AI codegen demo
2 parents c426246 + ec1abbb commit d2dfaaa

11 files changed

Lines changed: 502 additions & 4 deletions

File tree

.github/workflows/swift.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ jobs:
2222
.build/debug/hypercode emit Examples/service.hc --hcs Examples/service.hcs --format json > /tmp/ir-dev.json
2323
.build/debug/hypercode emit Examples/service.hc --hcs Examples/service.hcs --ctx env=production --format json > /tmp/ir-prod.json
2424
- name: Validate IR v2 against schema
25-
run: npm exec --yes --package=ajv-cli@5 -- ajv validate --spec=draft2020 -s Schema/hypercode-ir-v2.schema.json -d /tmp/ir-dev.json -d /tmp/ir-prod.json
25+
run: npm exec --yes --package=ajv-cli@5 -- ajv validate --spec=draft2020 -s Schema/hypercode-ir-v2.schema.json -d /tmp/ir-dev.json -d /tmp/ir-prod.json
26+
27+
- name: Codegen demo — generated artifacts fresh & contract-conformant
28+
run: python3 Examples/codegen-demo/check.py

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
.DS_Store
44
*.xcodeproj
55
site/
6+
__pycache__/
7+
*.pyc

DOCS/Usage.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,10 @@ specification, code is regenerated output.
198198
- Humans review the *specification diff*; machines expand it into code
199199
(review compression).
200200
201-
The missing piece for this loop is `hypercode diff <old.ir> <new.ir>` —
202-
HC-113 in the [work plan](../workplan.md).
201+
This loop is runnable today: [`Examples/codegen-demo/`](../Examples/codegen-demo/)
202+
generates a module per node, verifies freshness by node hash and contract
203+
conformance of the generated values in CI. `hypercode diff` (HC-113 in the
204+
[work plan](../workplan.md)) will productize the hash comparison.
203205
204206
## Scalar typing cheat-sheet
205207

Examples/codegen-demo/README.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# HC-124 — End-to-end AI codegen demo
2+
3+
The runnable version of the pipeline from [RFC §9.7](../../RFC/Hypercode.md)
4+
and [DOCS/Usage.md §5](../../DOCS/Usage.md): the spec is the durable artifact,
5+
code is regenerated output, and the resolved graph sits between them.
6+
7+
```
8+
Examples/service.{hc,hcs} ──▶ IR v2 ──▶ generated/*.py (one module per node)
9+
│ │ │
10+
1-line edit node hashes check.py validates artifacts
11+
(review this!) (what changed?) against the same contracts
12+
```
13+
14+
Every module in [`generated/`](generated/) was produced by Claude from the
15+
production-context IR of [`Examples/service.{hc,hcs}`](../service.hc). Each
16+
embeds the **hash of its source node** and every value carries its
17+
**provenance** as a comment:
18+
19+
```python
20+
# node: /Service/APIServer
21+
# hash: 7da0acd4b1617ed3…
22+
CONFIG = {
23+
"port": 8080, # APIServer > Listen @ Examples/service.hcs:26
24+
}
25+
```
26+
27+
## 1. Verify: artifacts match the spec
28+
29+
```console
30+
$ python3 check.py
31+
FRESH api_server.py /Service/APIServer
32+
FRESH database.py /Service/Database#main-db
33+
FRESH logger.py /Service/Logger.console
34+
FRESH service.py /Service
35+
36+
all modules fresh and contract-conformant
37+
```
38+
39+
`check.py` re-emits the IR and does two things: compares each module's
40+
embedded node hash against the current one (**freshness**), and validates the
41+
values embedded in the generated code against the `contracts[]` echoed in the
42+
IR (**conformance**): type and bounds for present keys, presence for required
43+
contracted keys, and no CONFIG key outside the resolved spec. Node paths use
44+
selector identity (`type[.class][#id]`) — the same addressing as
45+
`hypercode diff`. CI runs this on every push.
46+
47+
## 2. Scoped regeneration: a one-line spec edit
48+
49+
```console
50+
$ sed 's/port: 8080/port: 9090/' ../service.hcs > /tmp/edited.hcs
51+
$ python3 check.py --hcs /tmp/edited.hcs
52+
STALE api_server.py /Service/APIServer
53+
FRESH database.py /Service/Database#main-db
54+
FRESH logger.py /Service/Logger.console
55+
STALE service.py /Service
56+
57+
2 module(s) stale — regenerate with generate.sh
58+
```
59+
60+
The port lives on the `Listen` node, so exactly `api_server.py` is stale —
61+
plus the root wiring, because a node's hash is a Merkle hash over its subtree.
62+
`logger.py` and `database.py` are untouched and **not** regenerated. This is
63+
the review-compression loop: a human reviews the one-line spec diff; the
64+
machine knows precisely which artifacts it invalidates.
65+
66+
## 3. Guardrails on both sides of generation
67+
68+
**Before** generation, the spec itself is gated — a bad edit never reaches
69+
the generator:
70+
71+
```console
72+
$ hypercode validate bad.hc --hcs bad.hcs --ctx env=production
73+
bad.hcs:1:1: error[HC2104]: contract violation for 'port': 99999 exceeds upper bound 65535.0 …
74+
```
75+
76+
**After** generation, the artifacts are checked against the same contracts.
77+
Hand-edit `generated/api_server.py` to `"port": 99999` and:
78+
79+
```console
80+
$ python3 check.py
81+
contract violations in generated artifacts:
82+
error[HC2104-gen] api_server.py: 'port' = 99999 exceeds 65535.0 (contract 'APIServer > Listen')
83+
$ echo $?
84+
2
85+
```
86+
87+
The contract written once in the `.hcs` governs the spec, the resolved graph,
88+
and the generated code.
89+
90+
## 4. Regenerate with Claude
91+
92+
```console
93+
$ ./generate.sh # needs the `claude` CLI
94+
regenerating api_server.py from node /Service/APIServer …
95+
```
96+
97+
`check.py --list-stale` scopes the work; `generate.sh` feeds Claude the IR
98+
subtree of each stale node (the source of truth) plus the module conventions,
99+
then re-runs the checks. No stale module — no LLM call.
100+
101+
## 5. It actually runs
102+
103+
```console
104+
$ python3 generated/service.py
105+
('0.0.0.0', 8080)
106+
```
107+
108+
## Files
109+
110+
| File | Role |
111+
|---|---|
112+
| `check.py` | freshness (node hashes) + contract conformance of artifacts |
113+
| `generate.sh` | scoped regeneration of stale modules via `claude -p` |
114+
| `generated/*.py` | the generated service — one module per `.hc` node |

Examples/codegen-demo/check.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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

Comments
 (0)