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.
This commit is contained in:
bigmerge
2026-08-17 09:11:48 -05:00
parent c04d68f9ce
commit c741cfe928
4 changed files with 122 additions and 59 deletions
+30 -44
View File
@@ -3271,6 +3271,18 @@ fn params_to_env_args(params: [Any]) -> String {
fn cg_fn(stmt: Map<String, Any>) -> 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<String, Any>) -> 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<String, Any>) -> 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 <caller> calls <callee> 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<String, Any>) -> Bool {
let kind: String = expr["expr"]
if str_eq(kind, "Call") {
@@ -3486,7 +3484,7 @@ fn vbd_expr_has_restricted_call(expr: Map<String, Any>) -> 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<String, Any>]) -> 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