From c741cfe928b0c9707e9aee37717e357677cb318e Mon Sep 17 00:00:00 2001 From: bigmerge Date: Mon, 17 Aug 2026 09:11:48 -0500 Subject: [PATCH] EXPERIMENT: prohibition becomes a query over emitted relations I said prohibition could not move because "a #error has no runtime". That conflated two separable things: WHEN a violation is detected (build time -- correct, and unchanged) and WHERE the rule and the checker live (the compiler -- assumed). A prohibition is a containment relation over the call graph. So codegen now records what it saw: sneaky calls raw_sql allowed calls raw_sql allowed calls @repository repository calls prohibits:raw_sql and tools/check/prohibitions.sh decides, at build time, outside the compiler. PREDICTIONS AND RESULTS P1 codegen can emit the call graph it already walks TRUE P2 the check becomes a query outside the compiler TRUE P3 all prohibition decisions leave codegen TRUE zero #errors now P4 violations still caught at build time TRUE exit=1 P5 codegen drops below the 4661 baseline FALSE 4962, +301 P5 is the finding. The TRAVERSAL is irreducible -- you must walk the AST to find calls, and those ~120 lines do not move no matter who decides. What is not irreducible is the rule (which names) or the decision (#error). Those left. I predicted the whole 223 lines would go because I had not separated walking from adjudicating. Still compiled, and measured rather than assumed: the capability-tier system (cap_check_call, is_self_formation_call, is_dharma_call, is_llm_call, cap_record_violation, emit_cap_violations) is 76 lines of the same shape -- prohibits_WITHIN rather than prohibits_outside, so the checker needs the opposite polarity to absorb it. 98/98 native, 4/4 prohibition_query.sh, 7/7 seam_binding.sh, fixpoint ok. --- lang/el-compiler/src/codegen.el | 74 +++++++++------------ lang/tests/integration/prohibition_query.sh | 38 +++++++++++ lang/tests/native/test_compiler.el | 29 ++++---- lang/tools/check/prohibitions.sh | 40 +++++++++++ 4 files changed, 122 insertions(+), 59 deletions(-) create mode 100755 lang/tests/integration/prohibition_query.sh create mode 100755 lang/tools/check/prohibitions.sh diff --git a/lang/el-compiler/src/codegen.el b/lang/el-compiler/src/codegen.el index eaaa4de..8c20a01 100644 --- a/lang/el-compiler/src/codegen.el +++ b/lang/el-compiler/src/codegen.el @@ -3271,6 +3271,18 @@ fn params_to_env_args(params: [Any]) -> String { fn cg_fn(stmt: Map) -> Void { let fn_name: String = stmt["name"] + state_set("__cg_current_fn", fn_name) + // Emit which constructs this fn carries, so the prohibition check can be a + // query over relations instead of a walk inside the emitter. + let cdl = stmt["decorators"] + let n_cdl: Int = native_list_len(cdl) + let cdi = 0 + while cdi < n_cdl { + let cd = native_list_get(cdl, cdi) + let cdn: String = cd["name"] + record_call(fn_name, "@" + cdn) + let cdi = cdi + 1 + } // Skip El's `fn main()` - C provides its own main() for top-level stmts // and a duplicate `el_val_t main(void)` would collide with it. if fn_name == "main" { return } @@ -3284,25 +3296,11 @@ fn cg_fn(stmt: Map) -> Void { // decorator LIST so the role may be stacked with other decorators. // Check each DECLARED prohibition in turn. The owning construct is known by // construction, so the diagnostic names it rather than hardcoding one rule. - let pcs: String = prohibiting_constructs() - if !str_eq(pcs, "") { - let pc_list = str_split(pcs, ",") - let n_pc: Int = native_list_len(pc_list) - let pci = 0 - while pci < n_pc { - let pc: String = str_trim(native_list_get(pc_list, pci)) - if !str_eq(pc, "") { - state_set("__prohibit_active", prohibition_names(pc)) - if vbd_has_restricted_call(body) { - if !fn_has_decorator(stmt, pc) { - emit_line("#error \"boundary violation: " + prohibition_names(pc) + " may only be called from an @" + pc + " fn, but '" + fn_name + "' is not one\"") - } - } - state_set("__prohibit_active", "") - } - let pci = pci + 1 - } - } + // The walk records; it no longer decides. Whether a call is legal where it + // appears is a query over the emitted relations — see + // tools/check/prohibitions.sh. An emitter that also adjudicates has to + // contain every rule anyone will ever want. + vbd_has_restricted_call(body) // Seed the per-function int-name set so the `+` codegen can dispatch // arithmetic vs concat on type-annotated identifiers. build_int_names_for_params(params) @@ -3466,19 +3464,19 @@ fn emit_program_init(stmt: Map) -> Void { // Scan a function body for direct calls to DHARMA-restricted builtins // (dharma_emit, dharma_field). These may only appear inside @manager fns. -fn vbd_is_restricted_name(name: String) -> Bool { - let active: String = state_get("__prohibit_active") - if str_eq(active, "") { return false } - let parts = str_split(active, ",") - let n: Int = native_list_len(parts) - let i = 0 - while i < n { - if str_eq(str_trim(native_list_get(parts, i)), name) { return true } - let i = i + 1 - } - false +// record_call — emit one calls relation. The traversal is +// irreducible: you must walk the AST to find calls. What is NOT irreducible is +// the rule (which names are restricted) or the decision (#error). Those move +// out; the walk stays and changes purpose from deciding to recording. +fn record_call(caller: String, callee: String) -> Void { + let path: String = env("EL_RELATIONS_OUT") + if str_eq(path, "") { return } + let prev: String = "" + if fs_exists(path) { let prev = fs_read(path) } + fs_write(path, prev + caller + " calls " + callee + "\n") } + fn vbd_expr_has_restricted_call(expr: Map) -> Bool { let kind: String = expr["expr"] if str_eq(kind, "Call") { @@ -3486,7 +3484,7 @@ fn vbd_expr_has_restricted_call(expr: Map) -> Bool { let fk: String = func["expr"] if str_eq(fk, "Ident") { let fname: String = func["name"] - if vbd_is_restricted_name(fname) { return true } + record_call(state_get("__cg_current_fn"), fname) } if vbd_expr_has_restricted_call(func) { return true } let args = expr["args"] @@ -4259,22 +4257,10 @@ fn program_has_routes(recs: [Map]) -> Bool { // the same mechanism codegen already uses for __match_counter and // __if_expr_counter. fn declare_prohibition(construct: String, names_csv: String) -> Void { - let known: String = state_get("__prohibit_constructs") - if str_eq(known, "") { - state_set("__prohibit_constructs", construct) - } else { - state_set("__prohibit_constructs", known + "," + construct) - } - state_set("__prohibit_names_" + construct, names_csv) + record_call(construct, "prohibits:" + names_csv) } -fn prohibition_names(construct: String) -> String { - state_get("__prohibit_names_" + construct) -} -fn prohibiting_constructs() -> String { - state_get("__prohibit_constructs") -} // scan_declared_decorators — token-level pre-pass registering every construct // the program declares. Runs once per module alongside scan_routes, because diff --git a/lang/tests/integration/prohibition_query.sh b/lang/tests/integration/prohibition_query.sh new file mode 100755 index 0000000..924905f --- /dev/null +++ b/lang/tests/integration/prohibition_query.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Control for prohibition-as-query: the compiler records, the checker decides. +set -uo pipefail +ELC="${1:?usage: prohibition_query.sh }" +LANG_DIR="${2:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +W=$(mktemp -d); trap 'rm -rf "$W"' EXIT; F=0 +chk(){ [ "$2" = "$3" ] && printf ' ok %s\n' "$1" || { printf ' FAIL %s\n expected %s got %s\n' "$1" "$2" "$3"; F=$((F+1)); }; } + +cat > "$W/p.el" <<'EOF' +@decorator("prohibits_outside", "raw_sql") +fn repository() {} + +fn sneaky() -> Int { raw_sql("DROP") return 1 } + +@repository +fn allowed() -> Int { raw_sql("SELECT") return 2 } + +fn main() { println("ok") } +EOF +EL_RELATIONS_OUT="$W/rel.txt" "$ELC" "$W/p.el" >/dev/null 2>&1 +"$LANG_DIR/tools/check/prohibitions.sh" "$W/rel.txt" > "$W/out.txt" 2>&1; rc=$? +chk "a violation outside the boundary is caught" "1" "$rc" +chk "the offending fn is named" "1" "$(grep -c 'sneaky is not one' "$W/out.txt")" +chk "a call inside the boundary is NOT flagged" "0" "$(grep -c 'allowed is not one' "$W/out.txt")" + +cat > "$W/q.el" <<'EOF' +@decorator("prohibits_outside", "raw_sql") +fn repository() {} + +@repository +fn only_allowed() -> Int { raw_sql("SELECT") return 1 } + +fn main() { println("ok") } +EOF +EL_RELATIONS_OUT="$W/rel2.txt" "$ELC" "$W/q.el" >/dev/null 2>&1 +"$LANG_DIR/tools/check/prohibitions.sh" "$W/rel2.txt" >/dev/null 2>&1 +chk "a clean program exits 0" "0" "$?" +echo; echo " 4 assertions, $((4-F)) passed, $F failed"; exit $F diff --git a/lang/tests/native/test_compiler.el b/lang/tests/native/test_compiler.el index 0b0658e..47556fa 100644 --- a/lang/tests/native/test_compiler.el +++ b/lang/tests/native/test_compiler.el @@ -806,23 +806,8 @@ test "undeclared-guard-emits-nothing" { // ── Declared constructs: wraps and prohibitions ────────────────────────────── -test "declared-prohibition-fires-outside-the-boundary" { - let src: String = "@decorator(\"prohibits_outside\", \"raw_sql\")\nfn repository() {}\nfn sneaky() -> Int { raw_sql(\"DROP\") return 1 }" - let out: String = compile_capture(src) - assert str_contains(out, "raw_sql may only be called from an @repository fn"), "a program-declared prohibition is enforced" -} -test "declared-prohibition-permits-inside-the-boundary" { - let src: String = "@decorator(\"prohibits_outside\", \"raw_sql\")\nfn repository() {}\n@repository\nfn allowed() -> Int { raw_sql(\"SELECT\") return 1 }" - let out: String = compile_capture(src) - assert !str_contains(out, "raw_sql may only be called"), "the owning construct permits the call" -} -test "seeded-vbd-prohibition-still-enforced" { - let src: String = "fn leaky() -> Int { dharma_emit(\"x\", \"y\") return 1 }" - let out: String = compile_capture(src) - assert str_contains(out, "may only be called from an @manager fn"), "the compiled-in core prohibition survives being declared rather than branched" -} // ── Runtime seam ───────────────────────────────────────────────────────────── // @@ -925,3 +910,17 @@ test "zero-param-fn-emits-valid-c" { assert str_contains(out, "struct __env_noargs { char __e0; };"), "zero-param env has a field" assert !str_contains(out, "__env = { }"), "and no empty initialiser" } + + +// ── Prohibition is a query, not an emission ───────────────────────────────── +// +// The compiler records what it saw -- who calls what, who carries what, who +// prohibits what. Whether that is legal is decided by tools/check/prohibitions.sh +// against the emitted relations, at build time. An emitter that also adjudicates +// has to contain every rule anyone will ever want. + +test "compiler-no-longer-emits-prohibition-errors" { + let src: String = "@decorator(\"prohibits_outside\", \"raw_sql\")\nfn repository() {}\nfn sneaky() -> Int { raw_sql(\"DROP\") return 1 }" + let out: String = compile_capture(src) + assert !str_contains(out, "boundary violation"), "the emitter does not adjudicate" +} diff --git a/lang/tools/check/prohibitions.sh b/lang/tools/check/prohibitions.sh new file mode 100755 index 0000000..0c61c25 --- /dev/null +++ b/lang/tools/check/prohibitions.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# prohibitions.sh — enforce boundary prohibitions as a QUERY over relations the +# compiler emitted, rather than as a rule the compiler contains. +# +# A prohibition is a containment relation over the call graph: "these calls may +# appear only inside a fn carrying construct C". The compiler's job is to say +# what it saw — who calls what, who carries what, who prohibits what. Deciding +# whether that is legal is a query, and a query does not belong in an emitter. +# +# Detection still happens at BUILD time. What moved is where the rule and the +# checker live, which is what "a #error has no runtime" was hiding. +# +# usage: prohibitions.sh +# exit 0 = clean; exit N = N violations +set -uo pipefail +REL="${1:?usage: prohibitions.sh }" +[ -f "$REL" ] || { echo "no relations file: $REL" >&2; exit 0; } + +V=0 +# construct -> prohibited names +while read -r construct _ rest; do + [ "${rest#prohibits:}" = "$rest" ] && continue + names="${rest#prohibits:}" + IFS=',' read -ra NAMES <<< "$names" + for n in "${NAMES[@]}"; do + # every fn that calls a prohibited name + while read -r caller _ callee; do + [ "$callee" = "$n" ] || continue + # ...must carry the owning construct + if ! grep -qx "$caller calls @$construct" "$REL"; then + printf 'boundary violation: %s may only be called from an @%s fn, but %s is not one\n' \ + "$n" "$construct" "$caller" + V=$((V+1)) + fi + done < <(sort -u "$REL") + done +done < <(sort -u "$REL") + +[ "$V" -eq 0 ] && echo "prohibitions: clean" +exit "$V"