From dbea71208e1221289ecf3051d68a22a40d9782db Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:43:56 -0500 Subject: [PATCH 1/3] build: add a typecheck target and clear its findings `install-deps` already installed lua-language-server but nothing ran it, so the LuaCATS annotations were only checked by whoever had it wired into an editor. Adds a `typecheck` target pinned to a committed config, and fixes the two things it found. `runtime.version` in the config is load-bearing rather than boilerplate. Unset, the server defaults to Lua 5.4 and checks this library as the wrong language: under 5.4 it reports nothing, under LuaJIT two problems, so an absent version looks like a clean run. Pinned to LuaJIT, and `--configpath` keeps the result off whatever `.luarc.json` a developer happens to have. The two findings: - `_compat.lua` reused the `ok, result` locals from the native-operator probe for `pcall(require, "bit")` a hundred lines later. One holds a compiled chunk, the other a library table, so the reuse was misleading as well as untypeable. Separate locals. - `bit64.lua`'s selftest branched on `type(test.expected)` but kept indexing through the field, which the server does not narrow. Held in a local, which also drops the repetition. Both are annotation-level; no behaviour changes. 3/3 test modules pass, luacheck is clean across 7 files, and typecheck now reports nothing. --- .luarc-typecheck.json | 7 +++++++ CLAUDE.md | 17 +++++++++++++++++ Makefile | 24 ++++++++++++++++++++++++ src/bitn/_compat.lua | 10 ++++++---- src/bitn/bit64.lua | 21 ++++++++++++--------- 5 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 .luarc-typecheck.json diff --git a/.luarc-typecheck.json b/.luarc-typecheck.json new file mode 100644 index 0000000..69ca4c7 --- /dev/null +++ b/.luarc-typecheck.json @@ -0,0 +1,7 @@ +{ + "runtime": { "version": "LuaJIT" }, + "workspace": { + "useGitIgnore": false, + "ignoreDir": [".claude", ".git", ".idea", ".venv", "build", "dist", "node_modules"] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 629d976..0d124f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,10 +50,27 @@ make format # Lint code make lint +# Check LuaCATS annotations with lua-language-server +make typecheck + # Build single-file distribution make build ``` +### typecheck + +`make typecheck` runs lua-language-server against a committed +`.luarc-typecheck.json` rather than whatever `.luarc.json` a developer has +locally. It catches what luacheck does not: undefined or duplicate `@alias`, +returns that disagree with `@return`, fields missing from a `@class`. + +`runtime.version` in that config is load-bearing. Left unset the server defaults +to Lua 5.4 and checks this library as the wrong language, which reports a +different set of findings rather than fewer. It is pinned to LuaJIT. + +Currently clean. Unlike `lint` it is not part of `check`; keep it at zero and +that can change. + ## Architecture ### Module Design diff --git a/Makefile b/Makefile index 51f3f24..18b67cf 100644 --- a/Makefile +++ b/Makefile @@ -152,6 +152,29 @@ lint: exit 1; \ fi +# Type-check annotations with the Lua language server +# +# `install-deps` already installs lua-language-server, but nothing ran it, so the +# LuaCATS annotations were only checked by whoever had the server wired into +# their editor. It catches what luacheck does not: undefined or duplicate +# `@alias`, return counts that disagree with `@return`, fields missing from a +# `@class`. +# +# `runtime.version` in the pinned config is load-bearing. Unset, the server +# defaults to Lua 5.4 and silently checks this library as the wrong language; +# bitn reports different findings under 5.4, LuaJIT and 5.1. `--configpath` keeps +# the count off whatever `.luarc.json` a developer happens to have. +.PHONY: typecheck +typecheck: + @if command -v lua-language-server >/dev/null 2>&1; then \ + echo "Running lua-language-server $$(lua-language-server --version)..."; \ + lua-language-server --check "$(CURDIR)" --checklevel=Warning \ + --configpath="$(CURDIR)/.luarc-typecheck.json" --logpath="$(CURDIR)/build/luals"; \ + else \ + echo "lua-language-server not found. Install with: make install-deps"; \ + exit 1; \ + fi + .PHONY: check check: format-check lint @echo "Code quality checks complete." @@ -186,6 +209,7 @@ help: @echo " make format - Format code with stylua" @echo " make format-check - Check code formatting" @echo " make lint - Lint code with luacheck" + @echo " make typecheck - Check annotations with lua-language-server" @echo "" @echo "Setup:" @echo " make install-deps - Install development dependencies" diff --git a/src/bitn/_compat.lua b/src/bitn/_compat.lua index 17b2ac6..cdbf6a4 100644 --- a/src/bitn/_compat.lua +++ b/src/bitn/_compat.lua @@ -143,10 +143,12 @@ end local bit_lib local is_luajit = false --- Try LuaJIT's bit library first -ok, result = pcall(require, "bit") -if ok and result then - bit_lib = result +-- Try LuaJIT's bit library first. Fresh locals rather than the pcall results +-- from the native-operator probe above: that `result` is a compiled chunk, this +-- one is a library table. +local bit_ok, bit_module = pcall(require, "bit") +if bit_ok and bit_module then + bit_lib = bit_module is_luajit = true else -- Try Lua 5.2's bit32 library (use rawget to avoid recursion with our module name) diff --git a/src/bitn/bit64.lua b/src/bitn/bit64.lua index eb59e31..dfefdd7 100644 --- a/src/bitn/bit64.lua +++ b/src/bitn/bit64.lua @@ -942,20 +942,23 @@ function bit64.selftest() for _, test in ipairs(test_vectors) do total = total + 1 local result = test.fn(unpack_fn(test.inputs)) + -- Held in a local so the type narrowing below applies to it; a table field + -- is not narrowed by a `type()` check. + local expected = test.expected - if type(test.expected) == "table" then + if type(expected) == "table" then -- 64-bit comparison - if eq64(result, test.expected) then + if eq64(result, expected) then print(" PASS: " .. test.name) passed = passed + 1 else print(" FAIL: " .. test.name) - print(" Expected: " .. fmt64(test.expected)) + print(" Expected: " .. fmt64(expected)) print(" Got: " .. fmt64(result)) end - elseif type(test.expected) == "string" then + elseif type(expected) == "string" then -- Byte string comparison - if result == test.expected then + if result == expected then print(" PASS: " .. test.name) passed = passed + 1 else @@ -965,8 +968,8 @@ function bit64.selftest() print(" Got: " .. type(result)) else local exp_hex, got_hex = "", "" - for i = 1, #test.expected do - exp_hex = exp_hex .. string.format("%02X", string.byte(test.expected, i)) + for i = 1, #expected do + exp_hex = exp_hex .. string.format("%02X", string.byte(expected, i)) end for i = 1, #result do got_hex = got_hex .. string.format("%02X", string.byte(result, i)) @@ -976,12 +979,12 @@ function bit64.selftest() end end else - if result == test.expected then + if result == expected then print(" PASS: " .. test.name) passed = passed + 1 else print(" FAIL: " .. test.name) - print(" Expected: " .. tostring(test.expected)) + print(" Expected: " .. tostring(expected)) print(" Got: " .. tostring(result)) end end From 8133177df93b743afb1f448b0458fcdff4feb7f0 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:50:25 -0500 Subject: [PATCH 2/3] build: gate typecheck in check and install the server in CI The target from the previous commit was runnable but nothing ran it, which is the state it was added to fix. Adds it to `check`, which CI already invokes, and installs lua-language-server in the Check job since CI had luacheck and stylua but not the server. The version is pinned to 3.19.0 rather than floating. Findings genuinely move between versions, so an unpinned server would make CI red on an upstream release rather than on a change to this repo. Verified clean under 3.18.2-dev and 3.19.0. --- .github/workflows/build.yml | 8 ++++++++ CLAUDE.md | 4 ++-- Makefile | 17 +++++------------ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0b3059a..e2d606a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,14 @@ jobs: version: v2.1.0 args: false # Will be run as part of `make check` + - name: Install lua-language-server + run: | + curl -sL "https://github.com/LuaLS/lua-language-server/releases/download/$LUALS/lua-language-server-$LUALS-linux-x64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + env: + LUALS: 3.19.0 + - name: Check run: make check diff --git a/CLAUDE.md b/CLAUDE.md index 0d124f8..e161ef9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,8 +68,8 @@ returns that disagree with `@return`, fields missing from a `@class`. to Lua 5.4 and checks this library as the wrong language, which reports a different set of findings rather than fewer. It is pinned to LuaJIT. -Currently clean. Unlike `lint` it is not part of `check`; keep it at zero and -that can change. +Part of `check`, so CI enforces it. Clean under both 3.18.2 and 3.19.0; CI pins +3.19.0 because the findings do move between versions. ## Architecture diff --git a/Makefile b/Makefile index 18b67cf..65ec4bd 100644 --- a/Makefile +++ b/Makefile @@ -152,18 +152,11 @@ lint: exit 1; \ fi -# Type-check annotations with the Lua language server +# Type-check annotations with the Lua language server. Catches what luacheck does +# not: undefined or duplicate `@alias`, returns that disagree with `@return`. # -# `install-deps` already installs lua-language-server, but nothing ran it, so the -# LuaCATS annotations were only checked by whoever had the server wired into -# their editor. It catches what luacheck does not: undefined or duplicate -# `@alias`, return counts that disagree with `@return`, fields missing from a -# `@class`. -# -# `runtime.version` in the pinned config is load-bearing. Unset, the server -# defaults to Lua 5.4 and silently checks this library as the wrong language; -# bitn reports different findings under 5.4, LuaJIT and 5.1. `--configpath` keeps -# the count off whatever `.luarc.json` a developer happens to have. +# `runtime.version` in the pinned config is load-bearing: unset, the server +# defaults to Lua 5.4 and checks this library as the wrong language. .PHONY: typecheck typecheck: @if command -v lua-language-server >/dev/null 2>&1; then \ @@ -176,7 +169,7 @@ typecheck: fi .PHONY: check -check: format-check lint +check: format-check lint typecheck @echo "Code quality checks complete." # Clean generated files From b0e0d2643198ff9d703a098096e31229bc3ef2d2 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:53:13 -0500 Subject: [PATCH 3/3] build: exclude the CI toolchain dirs from typecheck The whole-repo scope means CI type-checks its own toolchain: leafo/gh-actions-lua installs into .lua and gh-actions-luarocks into .luarocks, both inside the workspace, so the first gated run reported 146 problems across 43 files, almost all of them in luacheck and luafilesystem sources rather than this repo. --- .luarc-typecheck.json | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.luarc-typecheck.json b/.luarc-typecheck.json index 69ca4c7..0a6ae01 100644 --- a/.luarc-typecheck.json +++ b/.luarc-typecheck.json @@ -1,7 +1,20 @@ { - "runtime": { "version": "LuaJIT" }, + "runtime": { + "version": "LuaJIT" + }, "workspace": { "useGitIgnore": false, - "ignoreDir": [".claude", ".git", ".idea", ".venv", "build", "dist", "node_modules"] + "ignoreDir": [ + ".claude", + ".git", + ".idea", + ".install", + ".lua", + ".luarocks", + ".venv", + "build", + "dist", + "node_modules" + ] } }