Skip to content

Commit 45c8c10

Browse files
wthollidayclaude
andcommitted
Reject indirect calls under --no-recursion
The direct-call SCC graph cannot see recursion smuggled through a captured var of function type (e.g. `var f = |x| 0; f = |x| f(x-1)`) or through a function passed as a parameter (e.g. `b(n) { a(b, n) }` where `a`'s body calls through the param). Tighten the rule so every call site must be a direct reference to a top-level function declaration: the callee must be an unshadowed `Expr::Id` that resolves to a named function. Anything else — call through a parameter, a var/let binding, a lambda expression, or a struct field — is reported as an indirect call at the call site. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e1db46f commit 45c8c10

2 files changed

Lines changed: 114 additions & 18 deletions

File tree

src/safety_checker.rs

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,14 +1147,20 @@ impl SafetyChecker {
11471147
}
11481148
}
11491149

1150-
/// Reject recursive functions (direct or mutual). Builds a static call
1151-
/// graph over user-defined functions (those with bodies) and runs
1152-
/// Tarjan's SCC algorithm; any SCC with size > 1 or a self-loop
1153-
/// produces a SafetyError pointing at the offending function(s).
1150+
/// Reject recursive functions (direct or mutual) and reject indirect
1151+
/// calls so that recursion cannot be smuggled in through a captured
1152+
/// var of function type or through a passed function parameter.
11541153
///
1155-
/// Only direct calls are modelled. Indirect calls through function
1156-
/// pointers or lambdas are ignored, which is sound for detecting
1157-
/// source-level recursion in DSP code.
1154+
/// The rule: every call must be a direct reference to a top-level
1155+
/// function declaration. An `Expr::Call` is direct iff its callee is
1156+
/// `Expr::Id(name)` where `name` is not locally bound in the enclosing
1157+
/// function and `name` resolves to at least one top-level function.
1158+
/// Anything else (calling through a parameter, a `var`/`let` binding,
1159+
/// a lambda expression, a struct field, etc.) is reported as an
1160+
/// indirect call at the call site.
1161+
///
1162+
/// Direct calls feed a static call graph; Tarjan's SCC flags any
1163+
/// cycle with size > 1 or a self-loop.
11581164
pub fn check_recursion(&mut self, decls: &DeclTable) {
11591165
use std::collections::{HashMap, HashSet};
11601166

@@ -1166,35 +1172,100 @@ impl SafetyChecker {
11661172
let mut nodes: Vec<usize> = Vec::new();
11671173
for (i, decl) in decls.decls.iter().enumerate() {
11681174
if let Decl::Func(f) = decl {
1169-
if f.body.is_some() && f.size_vars.is_empty() {
1175+
if f.body.is_some() {
11701176
node_of.insert(i, nodes.len());
11711177
nodes.push(i);
11721178
}
11731179
}
11741180
}
11751181

1176-
// 2. Build adjacency: for each function node, collect direct callees
1177-
// that are themselves nodes. An unresolved call name (local var,
1178-
// builtin without a body, non-function decl) contributes no edge.
1182+
// 2. Build adjacency AND report indirect calls. For each function,
1183+
// first compute the set of lexically-visible local names
1184+
// (parameters, var/let bindings, lambda parameters, for-loop
1185+
// vars) so we can tell when a call name is shadowed. Then walk
1186+
// every `Expr::Call`, classify it as direct or indirect, and
1187+
// either add an edge or emit a diagnostic.
11791188
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
11801189
for (node_idx, &decl_idx) in nodes.iter().enumerate() {
11811190
let Decl::Func(func) = &decls.decls[decl_idx] else {
11821191
continue;
11831192
};
1184-
let mut callees: HashSet<usize> = HashSet::new();
1193+
1194+
// Flat local-binding set. Over-approximates scope (a lambda
1195+
// param is treated as in-scope for the whole enclosing
1196+
// function), which is sound and only risks the mild false
1197+
// positive of a top-level function shadowed by a lambda
1198+
// parameter of the same name.
1199+
let mut local: HashSet<Name> = HashSet::new();
1200+
for p in &func.params {
1201+
local.insert(p.name);
1202+
}
11851203
for expr in &func.arena.exprs {
1186-
if let Expr::Call(callee_id, _) = expr {
1187-
if let Expr::Id(name) = &func.arena.exprs[*callee_id] {
1188-
for (i, cand) in decls.decls.iter().enumerate() {
1189-
if let Decl::Func(cand_fn) = cand {
1190-
if cand_fn.name == *name && cand_fn.body.is_some() {
1191-
if let Some(&cn) = node_of.get(&i) {
1204+
match expr {
1205+
Expr::Let(name, _, _) => {
1206+
local.insert(*name);
1207+
}
1208+
Expr::Var(name, _, _) => {
1209+
local.insert(*name);
1210+
}
1211+
Expr::For { var, .. } => {
1212+
local.insert(*var);
1213+
}
1214+
Expr::Lambda { params, .. } => {
1215+
for p in params {
1216+
local.insert(p.name);
1217+
}
1218+
}
1219+
_ => {}
1220+
}
1221+
}
1222+
1223+
let mut callees: HashSet<usize> = HashSet::new();
1224+
for (i, expr) in func.arena.exprs.iter().enumerate() {
1225+
let Expr::Call(callee_id, _) = expr else {
1226+
continue;
1227+
};
1228+
1229+
// Direct iff callee is an Id naming a top-level function
1230+
// that isn't shadowed by a local binding. Builtins and
1231+
// extern decls (body: None) still count as direct — they
1232+
// just contribute no edges to the graph.
1233+
let direct_name: Option<Name> = match &func.arena.exprs[*callee_id] {
1234+
Expr::Id(name) if !local.contains(name) => {
1235+
let resolves_to_fn = decls
1236+
.decls
1237+
.iter()
1238+
.any(|d| matches!(d, Decl::Func(df) if df.name == *name));
1239+
if resolves_to_fn {
1240+
Some(*name)
1241+
} else {
1242+
None
1243+
}
1244+
}
1245+
_ => None,
1246+
};
1247+
1248+
match direct_name {
1249+
Some(name) => {
1250+
for (di, d) in decls.decls.iter().enumerate() {
1251+
if let Decl::Func(df) = d {
1252+
if df.name == name && df.body.is_some() {
1253+
if let Some(&cn) = node_of.get(&di) {
11921254
callees.insert(cn);
11931255
}
11941256
}
11951257
}
11961258
}
11971259
}
1260+
None => {
1261+
self.errors.push(SafetyError {
1262+
location: func.arena.locs[i],
1263+
message:
1264+
"--no-recursion: indirect call is not allowed \
1265+
(callee must be a direct reference to a top-level function)"
1266+
.to_string(),
1267+
});
1268+
}
11981269
}
11991270
}
12001271
adj[node_idx] = callees.into_iter().collect();
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// args: --check --no-recursion
2+
// expected stdout:
3+
// ❌ ../tests/cases/checker/no_recursion_indirect.lyte:14:12: --no-recursion: indirect call is not allowed (callee must be a direct reference to a top-level function)
4+
// return f(n - 1) + 1
5+
// ^
6+
// ❌ ../tests/cases/checker/no_recursion_indirect.lyte:23:19: --no-recursion: indirect call is not allowed (callee must be a direct reference to a top-level function)
7+
// g = (|n: i32| g(n - 1))
8+
// ^
9+
//
10+
// Indirect calls cannot be modelled by the direct-call graph, so they
11+
// could otherwise smuggle recursion past --no-recursion via a captured
12+
// var holding a lambda or a function passed as a parameter.
13+
apply(f: i32 -> i32, n: i32) -> i32 {
14+
return f(n - 1) + 1
15+
}
16+
17+
good(n: i32) -> i32 {
18+
return n + 1
19+
}
20+
21+
main {
22+
var g = |n: i32| 0
23+
g = (|n: i32| g(n - 1))
24+
print(apply(good, 3))
25+
}

0 commit comments

Comments
 (0)