feat(cli): ludic deps --writes warns about writes through a local alias

A local bound straight from another module's global (let t =
thing_cur), or from such a local, is followed within its function, and
a write through its field or element is listed as a warning after the
counted writes. A reference from a function's result is not followed.
Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 13:22:37 +03:00
parent 8892f51096
commit 63a1fa1378
12 changed files with 43331 additions and 41501 deletions

View file

@ -1863,7 +1863,10 @@ the other, not counting a use of a module that itself uses none), `largest_cycle
of modules that all reach each other, named on the last line), `cross_writes` and
`globals_written_from_outside`. `--graph` lists each module with its declared `uses` and the edges
seen (`!` marks one its `uses` line does not name), `--dot` is the same for Graphviz with the cycle
filled, `--writes` lists the writes and `--uses MOD` who uses MOD. The largest cycle leaves out the
filled, `--writes` lists the writes - and then, as warnings not counted in the numbers, the writes
through a local bound straight to another module's global (`let t = thing_cur` then `t.used = 1`) -
and `--uses MOD` who uses MOD. A reference that reaches a local any other way (a function's result,
a field of another record) is not followed; that would need knowing where every reference can point. The largest cycle leaves out the
edges inside a declared layer, which may go round by design; the layers and the cycle counting their
own edges are printed after it. `--check FILE` fails when any
number is above FILE's `name value` lines; `--baseline FILE` writes them.

View file

@ -0,0 +1,6 @@
bump: patch
type: feature
**`ludic deps --writes` warns about a write through a local alias.** `let t = thing_cur` and then
`t.used = 1` writes another module's record just as `thing_cur.used = 1` does; a local bound straight
from another module's global (or from such a local) is now followed within its function and each
write through it listed as a warning. A reference that arrives from a function's result is not.

View file

@ -1,5 +1,6 @@
# tangle.ludic — the program `ludic deps` is tested on: a and b use each other (the largest cycle is
# 2), both use the leaf c (not counted), and a writes b's global b_count (one cross-module write).
# 2), both use the leaf c (not counted), and a writes b's global b_count (one cross-module write);
# a_bump writes b_rec through a local (a warning in ludic deps --writes, not counted).
#
# Running it prints: 4
import "tangle/c"
@ -7,6 +8,7 @@ import "tangle/a"
import "tangle/b"
program Tangle {
entry {
print(a_run())
a_bump()
print(a_run() + b_rec.n - 1)
}
}

View file

@ -7,3 +7,7 @@ export function a_run() -> int {
export function a_two() -> int {
return 2
}
export function a_bump() -> void {
let r = b_rec
r.n = r.n + 1
}

View file

@ -1,6 +1,10 @@
# b/index.ludic - uses a back: a and b are a cycle
module b
export var b_count: int = 0
export property Tally {
n: int = 0
}
export var b_rec: Tally = new Tally
export function b_run() -> int {
return a_two() + c_one()
}

View file

@ -498,7 +498,10 @@ function is_toggled_layer(name: pointer) -> bool {
function is_model(name: pointer) -> bool { return find_arch_id(name) > 0 }
# local variable environment
function loc_reset() -> void { nloc = 0 }
function loc_reset() -> void {
nloc = 0
deps_alias_reset() # emit_deps.ludic: aliases are a function's own
}
# push a local. Defaults to mutable (params, loop and query bindings are all
# reassignable/rebindable); a `let` binding marks its slot immutable afterward
# via loc_set_mut, so a later `name = …` can be rejected.

View file

@ -6,6 +6,7 @@
# module <name> <package 0|1> <uses: -|,a,b,> <layer: -|name>
# edge <from> <to> <count> <first name>
# write <owner> <global> <from> <file>:<line>
# alias <owner> <global> <from> <file>:<line> <local> (a write through a local, below)
var g_dp_on: int = -1
var g_dp_key: []pointer = new []pointer # "from to"
var g_dp_cnt: []int = new []int
@ -110,5 +111,79 @@ function deps_flush() -> void {
deps_line(f, g_dp_writes[i])
i += 1
}
i = 0
while i < len(g_dp_aliases) {
deps_line(f, g_dp_aliases[i])
i += 1
}
file_close(f)
}
# A local that holds another module's global record (or a piece of one) - `let t = thing_cur`,
# `let s = slots[i]` - writes into that module's state when it is written through: `t.used = 1`.
# That is caught here for a local bound straight from the global (or from such a local) in the
# function being lowered, and listed as `alias <owner> <global> <from> <file>:<line> <local>`. A
# reference that arrives any other way - returned by a function, read out of a field of another
# record - is not followed: that needs knowing where every reference can point, which this is not.
var g_dp_al_local: []pointer = new []pointer
var g_dp_al_owner: []pointer = new []pointer
var g_dp_al_global: []pointer = new []pointer
var g_dp_aliases: []pointer = new []pointer
function deps_alias_reset() -> void {
if not deps_on() { return }
g_dp_al_local = new []pointer
g_dp_al_owner = new []pointer
g_dp_al_global = new []pointer
}
function deps_alias_find(local: pointer) -> int {
var i = len(g_dp_al_local) - 1
while i >= 0 {
if (g_dp_al_local[i] == local) { return i }
i -= 1
}
return -1
}
function deps_alias_drop(local: pointer) -> void {
let k = deps_alias_find(local)
if k >= 0 { g_dp_al_local[k] = "" }
}
# the E_ID an index / field chain starts at
function deps_chain_root(t: Node) -> Node {
var n = t
while n != null and (n.kind == E_INDEX or n.kind == E_MEMBER) { n = n.a }
if n == null or n.kind != E_ID { return null }
return n
}
# `let local = <expr>`: an alias when the expression is another module's global, or an alias
function deps_alias_let(local: pointer, e: Node) -> void {
if not deps_on() or g_err_file == null { return }
deps_alias_drop(local)
if e == null { return }
let r = deps_chain_root(e)
if r == null { return }
if loc_find(r.s) >= 0 {
let k = deps_alias_find(r.s)
if k < 0 { return }
push(g_dp_al_local, local)
push(g_dp_al_owner, g_dp_al_owner[k])
push(g_dp_al_global, g_dp_al_global[k])
return
}
let g = find_global(r.s)
if g == null or g.file == null { return }
let owner = module_for_uses(g.file)
let from = module_of(g_err_file)
if (from == "") or (owner == "") or (from == owner) { return }
push(g_dp_al_local, local)
push(g_dp_al_owner, owner)
push(g_dp_al_global, r.s)
}
# an assignment through a local's field or element: a write into the state the local aliases
function deps_alias_write(t: Node) -> void {
if not deps_on() or g_err_file == null { return }
let r = deps_chain_root(t)
if r == null or loc_find(r.s) < 0 { return }
let k = deps_alias_find(r.s)
if k < 0 { return }
push(g_dp_aliases, `alias {g_dp_al_owner[k]} {g_dp_al_global[k]} {module_of(g_err_file)} {g_err_file}:{itoa(g_err_line)} {r.s}`)
}

View file

@ -36,6 +36,7 @@ function emit_assign(st: Node) -> void {
# a `let` binding is immutable: rebinding it is an error (mutating THROUGH
# it — `x.field = …`, `x[i] = …` — is fine, and lands in the branches below).
if loc_mut[li] == 0 { perr(`cannot assign to immutable '{t.s}' (declared with let; use var) — line {itoa(st.line)}`) }
if deps_on() { deps_alias_let(t.s, st.b) } # the local now holds what it is given
addr = loc_reg[li]; ty = loc_ty[li]
}
else {
@ -48,7 +49,7 @@ function emit_assign(st: Node) -> void {
} else {
if deps_on() {
let root = deps_target_root(t)
if root != null { deps_write(root, root.s) }
if root != null { deps_write(root, root.s) } else { deps_alias_write(t) }
}
if t.kind == E_MEMBER { addr = emit_member_addr(t); ty = g_addr_ty }
else { if t.kind == E_INDEX { addr = emit_index_addr(t); ty = g_addr_ty }
@ -359,6 +360,7 @@ function emit_stmt(st: Node) -> void {
emit_cov_hit(st.line) # --coverage: bump this line's hit counter (no-op otherwise)
if st.kind == S_LET {
loc_check_redeclared(st.s)
deps_alias_let(st.s, st.a)
var ty = st.ty
if (ty == null) { let v0 = emit_expr(st.a); ty = v0.ty
let slot = emit_alloca(llty(ty)); store_at(llty(ty), v0.code, slot); loc_push(st.s, slot, ty); loc_mark_declared(); loc_set_mut(st.ival); return }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,8 @@
# ludic deps [file] the numbers (the same five tools/deps.py printed)
# ludic deps --graph every module: its declared uses and the edges seen
# ludic deps --dot the graph for Graphviz (dot -Tsvg)
# ludic deps --writes every write to another module's global
# ludic deps --writes every write to another module's global, then (as warnings)
# every write through a local bound to one (let t = g; t.f = 1)
# ludic deps --uses MOD what the other modules use of MOD
# ludic deps --check FILE fail if any number is above FILE's
# ludic deps --baseline FILE write the numbers to FILE
@ -27,6 +28,7 @@ var dp_et: []int = null
var dp_ec: []int = null
var dp_en: []pointer = null
var dp_writes: []pointer = null
var dp_aliases: []pointer = null # writes through a local holding another module's global
var dp_wowner: []pointer = null
var dp_wname: []pointer = null
var dp_wfrom: []pointer = null
@ -62,7 +64,7 @@ function dp_load(path: pointer) -> bool {
if text == null { return false }
dp_mods = new []pointer; dp_pkg = new []int; dp_uses = new []pointer; dp_layer = new []pointer
dp_ef = new []int; dp_et = new []int; dp_ec = new []int; dp_en = new []pointer
dp_writes = new []pointer; dp_wowner = new []pointer; dp_wname = new []pointer; dp_wfrom = new []pointer
dp_writes = new []pointer; dp_aliases = new []pointer; dp_wowner = new []pointer; dp_wname = new []pointer; dp_wfrom = new []pointer
let lines = split_lines(text)
for i in 0 .. len(lines) {
let w = dp_words(lines[i])
@ -82,6 +84,7 @@ function dp_load(path: pointer) -> bool {
push(dp_ec, s_to_int(w[3]))
push(dp_en, w[4])
}
if len(w) >= 6 and w[0] == "alias" { push(dp_aliases, lines[i]) }
if len(w) >= 5 and w[0] == "write" {
push(dp_wowner, w[1])
push(dp_wname, w[2])
@ -396,6 +399,11 @@ function cmd_deps() -> int {
if mode == "graph" { dp_graph(); return 0 }
if mode == "writes" {
for w in 0 .. len(dp_writes) { print(sslice(dp_writes[w], 6, slen(dp_writes[w]))) }
# a warning, not a number: a write through a local bound to the global (not counted above)
for a in 0 .. len(dp_aliases) {
let w = dp_words(dp_aliases[a])
print(`warning: {w[1]} {w[2]} {w[3]} {w[4]} through the local {w[5]}`)
}
return 0
}
if mode == "uses" { dp_uses_of(arg2); return 0 }

View file

@ -485,6 +485,7 @@ function deps_case() -> void {
if shq(`bin/ludic deps {p} --check {base} > /dev/null`) { bad2(lbl, "--check passed with more dependencies than the baseline"); return }
if not shq(`bin/ludic deps {p} --dot | grep -q '"a" -> "b"'`) { bad2(lbl, "--dot has no a -> b edge"); return }
if not shq(`bin/ludic deps {p} --writes | grep -q 'b b_count a examples/modules/tangle/a/index.ludic:4'`) { bad2(lbl, "--writes misses a's write to b_count"); return }
if not shq(`bin/ludic deps {p} --writes | grep -q 'warning: b b_rec a examples/modules/tangle/a/index.ludic:12 through the local r'`) { bad2(lbl, "--writes misses the write through a local"); return }
let lay = capture(`bin/ludic deps examples/modules/layers.ludic 2>&1`)
if not s_contains(lay, "largest_cycle: 1") or not s_contains(lay, "layers: app (2: hud menu)") or not s_contains(lay, "counting the layers' own edges: 2") { bad2(lbl, `layers: [{s_trim(lay)}]`); return }
ok(lbl)