# LuaJIT trace checking

LuaJIT discovers an unsupported hot path only once that path is hot. Nupp finds
a smaller, deterministic set before the program runs: the operations the
selected recorder refuses whenever recording reaches them.

```nupp
@jit
local function sum(values: {integer}): integer
    local total: integer = 0
    for index = 1, #values do
        total = total + values[index]
    end
    return total
end
```

[`@jit`](../../reference/annotations.md#built-in-annotations) is a contract, so
every check and every build reports a refused operation the annotated body can
reach. Without the annotation the same analysis is available on request, and it
can also normalize the aborts observed in one profiled execution.

::: note
A clean static result does not promise that a function will run, become hot,
compile, or stay compiled for every input. It means there is no catalogued
unconditional blocker in the checked scope for that trace profile.
:::

## Result kinds

Every finding carries a class, and the class decides what it can do to a build.

| Result | Meaning | Can fail a build? |
| --- | --- | --- |
| blocker, must-reach | The recorder refuses the operation, and every repeatable path through the loop reaches it | `bc --check` exits 1; `@jit` is an error |
| blocker, may-reach | The recorder refuses it when that branch runs, but another path can avoid it | advisory from `bc --check`; `@jit` is an error |
| risk | Runtime types, call targets, ABI details, shapes, or recorder limits decide | warning or advice; selected conservative boundaries are errors under `@jit` |
| stop | Normal trace formation, such as leaving a loop or encountering recursion | informational and hidden by default |
| observed abort | The LuaJIT hook reported this event in one run | never changes whether source checks |

The operation-level answer and the reachability answer are deterministic.
Runtime observation is evidence from one input and one execution, so it is never
used as the static oracle.

## Checking surfaces

The same catalog answers from six places, which differ in when they run and how
much of the program they see.

| Surface | When it runs | What it checks | Runtime cost in an ordinary run |
| --- | --- | --- | --- |
| ordinary `nupp check` | automatically | enabled trace lints | none |
| `@jit` | automatically on every check and build | the annotated function and statically resolved checked callees | none |
| `nupp bc --check FILE` | when requested | exact generated bytecode and loop control flow | none; it does not execute the chunk |
| VS Code **Nupp: Check Function for JIT Trace Blockers** | when requested | the method or function under the cursor and resolved callees, using unsaved text | none |
| `nupp lsp trace-check --json FILE LINE COLUMN` | when requested | the same one-function query, for scripts and agents | none |
| `nupp run --jit-aborts FILE` | when requested | recorder events observed while that program runs | the trace callback costs something only during this session |

For continuous enforcement, put `@jit` on a function. For investigation, use the
VS Code command or lightbulb without changing source. A successful one-shot
check offers **Add `@jit` contract** when the declaration can carry it. See
[editors.md](../tooling/editors.md#visual-studio-code) for the extension that supplies it.

### Checking in CI

Two static scopes matter to a project, and they catch different things:

```bash
nupp check
nupp bc --check -O1 src/physics.nupp
nupp bc --check --json src/physics.nupp > build/physics-bytecode.json
```

`nupp check` enforces every `@jit` contract. `bc --check` catches blockers in
exact generated loop bytecode even where no source annotation was written. Its
Select the same `-O0`, `-O1`, or `-O2` level as the program run. JSON records
`optLevel`; both commands default to `-O0`. A tail call to `setmetatable` is
reported as a constructor risk, including in a helper without its own loop.

JSON includes the generated-artifact fingerprint, trace profile, catalog
version, prototype, PC, opcode, source line, reason ID, class, reachability,
explanation, and repair. See [`bc`](../../reference/cli.md#bc) for the command.

### Agent skill

The static-first performance workflow ejects as a focused skill:

```bash
nupp reference performance --skill \
  -o .agents/skills/nupp-performance/SKILL.md
```

The generated skill belongs to the compiler that produced it. Its trigger covers
slow code, hot loops, JIT behavior, profiler output, and performance
regressions; its body leads with trace checking before it asks the agent to
measure a process.

## Function construction in a loop

`FNEW` constructs a Lua function, and the selected LuaJIT recorder refuses it.
This capturing closure therefore breaks an `@jit` contract:

```nupp:refused
@jit
local function sum(values: {integer}): integer
    local total: integer = 0
    for index = 1, #values do
        local current = function(): integer
            return values[index]
        end
        total = total + current()
    end
    return total
end
```

```text
src/sum.nupp:5:33: error: NUPP2707: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles
 5 |         local current = function(): integer
   |                                 ^
note: trace classification: blocker (jit/loop-function-construction)
help: declare one function outside the loop and pass what varies to it
```

Move what varies into arguments to one function declared outside the loop:

```nupp
local function valueAt(values: {integer}, index: integer): integer
    return values[index]
end

@jit
local function sum(values: {integer}): integer
    local total: integer = 0
    for index = 1, #values do
        total = total + valueAt(values, index)
    end
    return total
end
```

No rewrite can safely lift every capturing closure, which is why the compiler
reports this rather than repairing it. Replacing the capture with shared mutable
state would change which value each returned function observes.

### Configurable source lint

Without `@jit`, a capturing loop closure is
[`jit-loop-closure`](../../reference/lints.md#jit-loop-closure). It is
off by default because the code is correct and some programs accept the
interpreted loop deliberately. Enable it in `nupp.lua` where this is a project
policy:

```lua
return {
    include = {"src"},
    lints = { ["jit-loop-closure"] = "warning" },
}
```

The same source then says:

```text
src/sum.nupp:4:33: warning: NUPP2515 jit-loop-closure: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles
note: trace classification: blocker (jit/loop-function-construction)
help: declare one function outside the loop and pass what varies to it
```

A non-capturing function is more directly repairable, so
[`loop-invariant-closure`](../../reference/lints.md#loop-invariant-closure)
remains its automatic diagnostic:

```nupp:fragment
for _, item in ipairs(items) do
    register(item, function(event)
        return event.kind == "click"
    end)
end
```

```text
src/events.nupp:2:28: warning: NUPP2505 loop-invariant-closure: this function is built once per iteration but does not use the iteration, so every one of them is the same function, and building one is what keeps the loop from compiling
help: declare it once above the loop and pass the name
```

## Blockers reached through checked calls

The contract follows resolved Nupp calls, including recursive call graphs and
exact exported callees. The error belongs to the annotated caller and shows a
bounded path:

```nupp:refused
local function helper(values: {integer}): integer
    local total: integer = 0
    for index = 1, #values do
        local current = function(): integer return values[index] end
        total = total + current()
    end
    return total
end

@jit
local function sum(values: {integer}): integer
    return helper(values)
end
```

```text
src/sum.nupp:12:12: error: NUPP2707: @jit call path sum -> helper reaches jit/loop-function-construction: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles
12 |     return helper(values)
   |            ^~~~~~
src/sum.nupp:4:33: note: the recorder blocker is here
note: trace classification: blocker (jit/loop-function-construction)
help: declare one function outside the loop and pass what varies to it
```

Recursive strongly connected components reach a fixed point over the reason set,
so the path does not expand forever.

## Explicit `jit.off` boundaries

Calling a function deliberately disabled with `jit.off` is valid ordinary code.
It is an error from an `@jit` body, because that body promised not to leave
compiled code deliberately:

```nupp:refused
local function logValue(value: integer): nil
    print(value)
end

jit.off(logValue)

@jit
local function update(value: integer): nil
    logValue(value)
end
```

```text
src/update.nupp:9:5: error: NUPP2707: @jit call path update -> logValue reaches jit/disabled-callee: the resolved callee is explicitly disabled with jit.off
 9 |     logValue(value)
   |     ^~~~~~~~
note: trace classification: blocker (jit/disabled-callee)
help: remove @jit from the caller or keep the disabled call outside its checked hot path
```

Move the logging call outside the annotated operation, or remove `@jit` when the
interpreter transition is intentional. Applying `jit.off` to the enclosing
function also makes a local trace lint irrelevant: a function taken off the JIT
has no trace to lose.

## Variadic FFI

A variadic C signature depends on the exact argument types and target ABI. Nupp
classifies it as a conservative risk rather than as a universal opcode blocker:

```nupp
cdef function printf(format: cstring, ...): int32

local function report(value: int32): nil
    printf("%d", value)
end
```

```text
src/report.nupp:4:5: warning: NUPP2514 jit-boundary: a variadic FFI call cannot safely execute on a compiled trace
 4 |     printf("%d", value)
   |     ^~~~~~
note: trace classification: risk (jit/ffi-vararg-policy)
help: move the call behind an explicit jit.off boundary when it is not a hot operation
```

Under `@jit`, the same site is a non-suppressible contract error. A clear
boundary makes the choice explicit:

```nupp
cdef function printf(format: cstring, ...): int32

local function reportCold(value: int32): nil
    printf("%d", value)
end

jit.off(reportCold)
```

Do not call `reportCold` from an `@jit` function; that would correctly become
the `jit/disabled-callee` error shown above. See
[`jit-boundary`](../../reference/lints.md#jit-boundary) for the lint, and
[c-interop.md](../runtime/c-interop/index.md#jit-sensitive-c-boundaries) for the C
side of the same boundary.

## Lua callbacks passed through C

C cannot safely re-enter an ordinary Lua callback from a compiled trace. The
checker uses the resolved FFI signature and callback identity rather than
guessing from a generated form:

```nupp
cdef function each(fn: function(int32), n: int32)

local function visit(value: int32): nil
    print(value)
end

local function run(): nil
    each(visit, 1)
end
```

```text
src/callback.nupp:8:10: warning: NUPP2502 jit-callback: a Lua function passed to C becomes an FFI callback and cannot run from a compiled trace
 8 |     each(visit, 1)
   |          ^~~~~
note: trace classification: risk (jit/ffi-callback)
help: disable the callback and its calling boundary with jit.off
```

Keep the complete C-to-Lua callback boundary cold:

```nupp:fragment
local function visit(value: int32): nil
    print(value)
end

local function runCold(): nil
    each(visit, 1)
end

jit.off(visit)
jit.off(runCold)
```

Inside `@jit`, allowing the
[`jit-callback`](../../reference/lints.md#jit-callback) lint does not waive the
contract. It remains a contract error.

## Dynamic call targets

An unresolved call is not fabricated into a blocker. The one-shot inspection
reports what is actually known:

```nupp
local function dispatch(callback: any): nil
    callback()
end
```

```bash
nupp lsp trace-check --json src/dispatch.nupp 2 5
```

```json
{
  "functionName": "dispatch",
  "contract": "inspection",
  "findings": [{
    "reason": "jit/dynamic-call",
    "class": "risk",
    "message": "the call target is dynamic, so its bytecode recordability is unknown",
    "callPath": ["dispatch"]
  }],
  "reasonCatalog": {"id": "nupp-trace-reasons-v1", "version": 1}
}
```

That risk does not mean every possible target is bad. Give the call a statically
resolved checked identity where continuous transport is required.

## `FNEW` and `UCLO` in generated bytecode

`bc --check` compiles but does not execute the exact artifact at the requested
optimization level. A capturing closure commonly produces both function
construction (`FNEW`) and upvalue closing (`UCLO`) in the repeatable region:

```text
$ nupp bc --check src/sum.nupp
nupp: 2 instructions in a loop that cannot compile
...
0006  FNEW  6  0  <-- this loop never compiles: LuaJIT has no recorder for constructing a function [jit/loop-function-construction, must-reach]
...
0010  UCLO  5 => 0011  <-- this loop never compiles: LuaJIT has no recorder for closing an upvalue [jit/loop-upvalue-close, must-reach]
0011  FORL  2 => 0006
```

The command exits 1 because every repeatable path reaches both blockers.

A conditional non-capturing construction can be only may-reach:

```nupp:fragment
local current = function(): integer return 0 end
local first = true
for index = 1, #values do
    if first then
        current = function(): integer return 1 end
        first = false
    end
end
```

```text
0007  ISF       2
0008  JMP   7 => 0011
0009  FNEW  1  1  <-- this path aborts recording: jit/loop-function-construction [may-reach]
0011  FORL  3 => 0007
```

That finding is visible, but `bc --check` exits 0 because it cannot truthfully
claim the whole loop always fails to complete a root trace. `@jit` is stronger
and rejects a statically reachable may-reach blocker, because its contract
covers every checked path.

## Manual checking in VS Code

Place the cursor anywhere in a method or function and run **Nupp: Check Function
for JIT Trace Blockers** from the Command Palette, editor context menu, or
lightbulb. The request:

- selects the smallest enclosing function;
- reads the current unsaved editor buffer;
- follows statically resolved checked callees;
- puts findings in the temporary **Nupp JIT Check** diagnostic collection; and
- clears those diagnostics on edit or on the next manual check.

<img src="/images/vscode-jit-trace-check.jpg" width="1224" height="768" style="max-width: 100%; height: auto" alt="VS Code showing Nupp syntax highlighting, a function created inside a loop underlined in red, and Nupp JIT Check findings in the Problems panel">

Here, the manual check flags a function created on every iteration. Open the
**Problems** panel to see the findings; hover over an underline for the full
explanation.

It does not add an annotation, run another process, execute the program, or
attach a trace hook. A clean answer says:

```text
update: no catalogued unconditional trace blockers or conditional risks.
```

That is deliberately not "this function will compile." Choose **Add `@jit`
contract** afterwards when the function should be checked continuously.

## Observing real aborts

Static checking cannot know whether a loop runs or becomes hot. Observe one
workload when that is the question:

```bash
nupp run --jit-aborts app.nupp
nupp run --jit-aborts=jit-aborts.json --json app.nupp
```

CSV remains compatible with existing consumers:

```csv
severity,count,reason,location,zone
warn,7,NYI: bytecode FNEW,app.nupp:41,frame/spawn
```

JSON retains the raw VM detail and adds the stable identity. A location is a
file and the range of its line, the shape every other `--json` report uses, and
`class` stands where the CSV's `severity` does; `blacklisted` says whether LuaJIT
stopped retrying the trace:

```json
{
  "durationMs": 412.5,
  "totalAborts": 7,
  "blacklisted": 0,
  "reasonCatalog": {"id": "nupp-trace-reasons-v1", "version": 1},
  "sites": [{
    "count": 7,
    "reason": "NYI: bytecode FNEW",
    "rawReason": "NYI: bytecode FNEW",
    "reasonId": "jit/loop-function-construction",
    "class": "blocker",
    "blacklisted": false,
    "location": {
      "file": "app.nupp",
      "range": {
        "start": {"line": 41, "column": 1, "offset": 1180},
        "end": {"line": 41, "column": 38, "offset": 1217}
      }
    },
    "zone": "frame/spawn"
  }]
}
```

The callback runs on recorder events, not on every loop iteration or table
access, but it still has nonzero profiling cost. Stop the session after the
interesting window. With no trace session, no callback is attached and no
aggregation state is allocated. See [profiling.md](profiling.md#trace-aborts)
for collecting the same events from inside a program.

## Reason catalog

Static and runtime surfaces use the same identities, published as
`nupp-trace-reasons-v1`. Raw LuaJIT strings remain report detail rather than
public identifiers, and `nupp explain REASON` prints the current explanation
plus a repair only where Nupp has a specific semantics-preserving one.

### Statically attributable reasons

| Stable reason | Class | What the diagnostic says | Repair or interpretation |
| --- | --- | --- | --- |
| `jit/loop-function-construction` | blocker | LuaJIT has no recorder for constructing a function | Declare one function outside the loop and pass what varies |
| `jit/loop-upvalue-close` | blocker | LuaJIT has no recorder for closing an upvalue | Move the captured lifetime outside the repeated region |
| `jit/ffi-vararg-policy` | risk | This variadic FFI form depends on argument types and the target ABI | Put an intentionally cold call behind `jit.off` |
| `jit/ffi-callback` | risk | A C call that re-enters Lua through this callback cannot remain on a trace | Disable the callback and its calling boundary |
| `jit/disabled-callee` | blocker | The resolved callee is explicitly disabled with `jit.off` | Keep the call outside `@jit`, or remove the contract |
| `jit/dynamic-call` | risk | The call target is dynamic, so its bytecode recordability is unknown | Resolve the target; do not assume every target is bad |

### Expected stops

These are class `stop` and severity `info`, and they are omitted unless benign
events are requested. They are not warnings to fix.

| Stable reason | Typical raw VM detail | Meaning |
| --- | --- | --- |
| `jit/expected-loop-leave` | `leaving loop in root trace` | The recorded path left the loop |
| `jit/expected-inner-loop` | `inner loop in root trace` | Trace formation encountered an inner loop |
| `jit/expected-down-recursion` | `down-recursion, restarting` | The recorder restarted at a recursive callee |
| `jit/expected-up-recursion` | `up-recursion` | The trace reached an upward recursive edge |
| `jit/retry-recording` | `retry recording` | LuaJIT requested an ordinary retry |
| `jit/runtime-trace-too-short` | `trace too short` | The attempted trace did not contain enough work |

### Runtime risks and blockers

These are observations from the active run. Counts and timing change with
inputs; the normalized identity does not.

| Stable reason | Class | Typical raw VM detail | What the report means |
| --- | --- | --- | --- |
| `jit/runtime-blacklisted` | risk | `blacklisted` | LuaJIT permanently demoted that trace in this process |
| `jit/runtime-trace-too-long` | risk | `trace too long` | The recorder reached its trace-length limit |
| `jit/runtime-trace-too-deep` | risk | `trace too deep` | Nested recording exceeded the depth limit |
| `jit/runtime-too-many-snapshots` | risk | `too many snapshots` | Guard and snapshot capacity was exhausted |
| `jit/runtime-loop-unroll-limit` | risk | `loop unroll limit reached` | Recording exceeded its loop-unroll allowance |
| `jit/runtime-call-unroll-limit` | risk | `call unroll limit reached` | Recording exceeded its call-unroll allowance |
| `jit/runtime-disabled-callee` | blocker | `JIT compilation disabled for function` | The observed path entered a disabled function |
| `jit/runtime-ffi-call` | risk | `NYI: unsupported C function type` | This runtime C signature was unsupported |
| `jit/runtime-ffi-conversion` | risk | `NYI: unsupported C type conversion` | This runtime C conversion was unsupported |
| `jit/runtime-type-instability` | risk | `persistent type instability` | Repeated attempts observed incompatible runtime types |
| `jit/runtime-machine-code-limit` | risk | `machine code too long`, `hit mcode limit (retrying)`, or `failed to allocate mcode memory` | Machine-code size or allocation limits stopped compilation |
| `jit/runtime-recorder-error` | risk | `error thrown or hook called during recording` | A language error or hook interrupted recording |
| `jit/runtime-unknown` | risk | the VM revision and unrecognized raw payload | The event stays visible, with no guessed explanation or repair |

An observed `NYI: bytecode FNEW` or `UCLO` maps back to the corresponding static
blocker rather than receiving a second runtime-only identity.

## Profiles, versions, and reproducibility

Every answer names its `TraceProfile`: LuaJIT revision, architecture, operating
system, enabled recorder features, bytecode schema, and reason-catalog version.
Reports from different profiles are not merged. The bytecode fingerprint
prevents a static result for an old artifact from being presented as though it
described new code.

Adding a newly proved unconditional blocker creates a new catalog identity and
version, and it can make an existing `@jit` contract fail after a compiler
upgrade.

::: deepdive Evidence for static checks
The bar for promoting an observation to a static rule is a deterministic VM
fixture, a neighboring accepted fixture, source attribution, and a specific
working alternative. One application abort is evidence to investigate rather
than enough to generalize: a rule added from a single measurement would fail
builds for every program whose only fault was resembling that one.
:::

## Trace capacity limits

LuaJIT limits how much compiled code a process keeps, and reaching a limit
throws all of it away: every trace is flushed and the program runs interpreted
until it earns them back. Every process Nupp starts, whether the compiler, a
`nupp run` program, or a stamped executable, raises those limits before its
first trace:

| Limit | LuaJIT's default | Nupp's |
| --- | --- | --- |
| `maxtrace`, live traces | 1,000 | 20,000 |
| `maxside`, side traces per root trace | 100 | 1,000 |
| `sizemcode`, the machine-code area | 64 KB | 16 MB |
| `maxmcode`, all machine code | 2 MB | 16 MB |

The area matters most on arm64 macOS. A trace has to stay within branch range of
the interpreter, and once the heap has grown, the operating system places a
second area out of range. With the default 64 KB area, a process flushed each
time a couple of hundred traces filled it. Nupp reserves one large area while the
process is still small. On a cold `nupp check` of the compiler's own sources,
this took the run from 291 flushes to none and cut its time by about a fifth.

A loop reached with more than 100 different closure prototypes or shapes used
to fall back to the interpreter for the rest. At 1,000 it compiles them all.

`NUPP_JIT_DEFAULT=1` restores LuaJIT's own limits and thresholds, and
`bench/jit-limits.lua` measures the difference. A program can still call
`jit.opt.start` itself; its own settings apply from then on.

## Large-application workflow

Use static checking to keep known cliffs from spreading through checked call
graphs, then profile to prioritize what remains:

1. Put `@jit` on important subsystem boundaries, not on every tiny helper.
2. Run `bc --check` on release entry modules at their release optimization
   level.
3. Use the VS Code one-shot check while investigating a method, before
   committing to a contract.
4. Sample first to find interpreted hot frames, then collect aborts over a
   short, representative window.
5. Fix must-reach blockers first, then observed blacklists, then risks that
   overlap measured interpreted time.
6. Keep benchmarks: removing an abort does not prove the replacement is faster.

Static checking removes the catalogued, attributable causes. It does not replace
profiling for cold code, input-dependent types and shapes, side exits, recorder
limits, or deciding whether a reported site matters to the workload.

## FAQ

### Does a clean `@jit` result mean the function is compiled?

No. It means no catalogued unconditional blocker is reachable in the checked
scope for that trace profile. Whether the function runs at all, becomes hot, and
stays compiled is a runtime question, answered by
[profiling.md](profiling.md#trace-aborts).

### Why is a variadic FFI call a risk rather than a blocker?

The answer depends on the exact argument types and the target ABI, so no static
rule covers every form. Under `@jit` the conservative boundary is still an
error, because the contract covers every checked path.

### When is `@aot` the answer instead of a trace contract?

When the loop is numeric, maps over spans, and the measured gap is codegen
rather than an aborted trace. See [ahead-of-time.md](ahead-of-time/index.md) for the
subset `@aot` admits and what it costs.

::: seealso
- [performance.md](index.md) for the rewrites the compiler applies before
  LuaJIT sees the code
- [profiling.md](profiling.md) for where a running program spends its time
- [lints.md](../../reference/lints.md#jit-loop-closure) for the trace lints and
  their severities
- [`bc`](../../reference/cli.md#bc) for the bytecode command's options and JSON
:::
