Lupa
v0.1.0Static analyzer for Wippy typed Lua — untyped parameters, weak types, naming, file shape, clones and architecture layers, from the wippy-lua tree-sitter AST
Run
wippy run wippyai-contrib/lupalupa
Static analyzer for Wippy's typed Lua. Parses each source file with the real
wippy-lua tree-sitter grammar (standard Lua + Wippy's gradual types) and reports
findings from the AST — not regex.
Runs on the host, no Docker — it is just static analysis, like ls.
Run
From this repository, pointed at the project you want to analyze:
../lupa/bin # or a symlink to it
../lupa/bin 2>/dev/null # findings only (runtime boot logs go to stderr)
Installed into a Wippy project, where the application already provides a host:
wippy add wippyai-contrib/lupa
wippy install
wippy run -x wippyai-contrib.lupa:run --host <your terminal.host> \
--override wippyai-contrib.lupa:source_fs:directory="$PWD"
The published module ships no host of its own — wippyai-contrib.lupa.host:** is excluded from the
pack, the same way wippy/test takes its host from the installing application. A
project with no host can add one with wippy add wippy/terminal
(wippy.terminal:host).
Findings go to stdout, one per line:
wippy/src/app/notify/dispatch.lua:50:21: [untyped] parameter `channel` has no type
...
lupa: 4598 finding(s) in 351 files untyped=2737 naming=621 weak-types=1240
Format: file:line:col: [rule] message.
Config — .lupa.yaml
Put .lupa.yaml in the analyzed project's root. It configures every rule.
rules:
untyped: { enabled: true }
weak-types: { enabled: true }
naming:
enabled: true
abbrev: # extend the abbreviation → full-name map
ch: channel
cont: contact
file-size: { enabled: true, min: 10, max: 500 }
file-functions: { enabled: true, max: 3 }
duplication:
enabled: true
min_lines: 6 # a clone must span at least this many source lines
min_statements: 4 # ...and at least this many consecutive sibling statements
cross_module_only: false
ignore: # globs (and plain dir names); hidden dirs (.*) are always skipped
- "**/*_test.lua"
- wippy-upstream
- node_modules
Defaults if .lupa.yaml is absent: all rules on, ignore *_test.lua.
Rules
untyped — a function parameter with no type
What: a parameter declared without a type annotation (function M.f(user_id)).
The grammar sees a typed_parameter node with no type field.
Fix: declare the parameter's real type at the signature:
function M.create(user_id) -- untyped
function M.create(user_id: string) -- fixed
If a concrete type would break any-typed callers (e.g. a raw db handle passed
through an untyped chain), that is real typing debt — type the chain or leave it
knowingly; the finding marks it, it does not force a bad type.
weak-types — the any type
What: the type any used anywhere (parameter, return, local, or :: / as
cast). any opts out of type checking, so it hides mistakes.
Fix: replace any with the real shape:
function M.confirm(input: any) -- weak
function M.confirm(input: { user_id: string }) -- fixed
local db = raw :: any -- weak cast
local db = raw :: sql.DB -- fixed cast
For values that are genuinely dynamic at a trust boundary (raw JSON body, a framework value with no exposed type), validate the required fields and cast into a declared record/entity type — then that struct is the trusted type.
naming — bad variable names
What: a variable/parameter name that is (a) a single character (c, i, x),
or (b) a known abbreviation (ch, cont, usr, msg, …). Names must be
descriptive, precise, and short.
Fix: rename to the word:
for i, ch in ipairs(cont) do end -- i, ch, cont
for index, channel in ipairs(contact) do end -- fixed
Do not over-qualify: the right name is contact, not assignedToMessageContact.
Exempt: M (module table), _ (discard), self. Extend the abbreviation map via
.lupa.yaml → rules.naming.abbrev.
file-size — a file too thin or too fat
What: a .lua file under min lines (default 10) or over max (default 500),
reported once at line 1.
Fix: a sub-10-line file is a shim — fold it into the module it re-exports (a
registry entry points at the real library via method:, it does not need its own
5-line file). A 500+ line file carries more than one concern — split by concern, not
by line count.
file-functions — too many functions in one file
What: a .lua file declaring more than max functions (default 3), counting
function_declaration nodes (local function f, function M.f), reported at line 1.
Fix: split the file by concern. Not "one function per file" — group the functions that serve one concern, move the rest to their own file.
Both file rules skip whatever ignore skips, so *_test.lua is out by default.
name-stutter — an entry name that repeats its own namespace
What: a code entry whose name contains a token already present in its namespace.
app.user.api:settings_profile_get says api twice; app.billing.api:cancel
says both billing and api twice. The id is the full address, so every repeated segment
is noise the reader has to skip.
Namespace segments are compared after the layer prefix (app / bridge / contract), so
app.letter:runtime is flagged on letter while app.letter:read_token is not.
Only source kinds are checked (function.lua, library.lua, process.lua) —
an http.endpoint takes its name from the function it routes to, and the route-name rule
already pins that shape, so renaming the function renames the endpoint with it. workflow.lua
is excluded for a harder reason: a workflow entry's id is its Temporal workflow type, and
that type is recorded in the history of every running execution. Renaming one strands the
executions already in flight — the worker no longer registers the type they were started
with, and their next task fails with "unable to find workflow type".
exempt_namespaces skips namespaces where the repetition is the convention. %.repo$ is
exempt by default here: ARCHITECTURE reserves bare aggregate names for repo/, so
app.contact.repo:contact is correct, not stutter.
Fix: drop the repeated tokens — the finding prints the trimmed name. Two entries need a
human instead of a trim, because trimming leaves nothing: an entry named exactly after its
module (app.authz:policy) has to say what it actually is (policy, core, …).
duplication — the same code shape in more than one place
What: copy-pasted code, found on the AST rather than on text. Every node's named
children form a statement sequence; a sliding window of min_statements consecutive
statements is fingerprinted by the s-expression of each statement, so identifiers,
literals and formatting are ignored and only the structure is compared. A window is
reported when it spans at least min_lines source lines and occurs in more than one
place. Overlapping windows collapse into the longest one, so a 60-line clone is one
finding, not fifty nested ones.
A finding names the span, the number of sites, whether they sit in one module
(intra-module) or several (cross-module), and the other sites' locations.
require_control_flow: true (the default) skips a window whose statements contain no
branching at all. A run of named one-query repo functions, or a block of plain field
assignments, is structurally identical by construction — that is a declaration list, not
duplicated logic, and collapsing it would destroy the names it exists to provide.
cross_module_only: true reports only clones that cross a module boundary — the
subset worth arguing about when intra-module duplication is a known backlog.
Fix: intra-module clones usually mean a missing shared file inside the module
(a *_shared.lua beside the handlers). Cross-module clones mean the shape belongs in
substrate (app.infra:*) or in a port the modules already share. Not every clone is
worth removing — this rule is a backlog metric, never a gate.
Alongside the findings, the run prints duplicated source lines per module: the share of a module's non-blank, non-comment lines that sit inside at least one detected clone. Use it to rank modules, not as a target to drive to zero — a module of small repetitive repo functions will always score non-trivially.
architecture — forbidden dependency edges
What: a registry entry referencing an entry that its layer may not reach. References
come from the entry's imports: and from any namespace:id string literal in its
source, so funcs.call("app.notify:deliver") counts as an edge.
arch.partitions holds one or more independent cuts of the same codebase. Each has its
own layers (name + one pattern or a list of patterns matched against the entry id),
allow list of permitted from → to edges, substrate patterns that any layer may
reach, and a severity:
gate— a violation makes the run exit non-zero (make arch,make check).report— printed like any other finding, exit code untouched. Use it for a cut that is being introduced: land the partition, work the violation list down, then flip it togateso it can never regress.
The severity is printed with the finding, e.g. [contexts/report] vs [hexagon/gate].
An edge inside one layer is always allowed; a partition never constrains what it does
not name.
The legacy single-cut form (arch.layers + arch.allow at the top level) still parses
and behaves as one gate partition.
Requirements
lupa parses with the wippy-lua tree-sitter grammar
(github.com/xepozz/tree-sitter-wippy-lua — standard Lua plus Wippy's gradual types).
That grammar is not compiled into the released wippy binary: the runtime's grammar
list is fixed at build time and registers lua, not wippy-lua. lupa therefore needs a
runtime built with the grammar linked in, and treesitter.parse("wippy-lua", ...) fails
on a stock binary.
bin (wrapper) → libexec/wippy (that runtime) → src (the analyzer registry).
Development
make check # lint + the colocated suites + lupa on itself (expected: 0 findings)
wippyai-contrib.lupa.host:** is host wiring for running lupa from this repository. It is excluded
from the published module by wippy.yaml.