Lupa

v0.1.0
0 PublicLibrary

Static analyzer for Wippy typed Lua — untyped parameters, weak types, naming, file shape, clones and architecture layers, from the wippy-lua tree-sitter AST

MIT 62 downloads
Updated 3 days ago Repository
lintstatic-analysisluatreesitterarchitecture

Run

wippy run wippyai-contrib/lupa

lupa

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.yamlrules.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 to gate so 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.