From 4614f3e21d99346557eb83f1b87aa845768f25d3 Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:14:11 +0900 Subject: [PATCH 1/5] feat(resolve): add --offline to skip version resolution Co-Authored-By: Claude Opus 5.5 --- lua/nvimx/resolve.lua | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lua/nvimx/resolve.lua b/lua/nvimx/resolve.lua index 2f03ad5..705dbec 100644 --- a/lua/nvimx/resolve.lua +++ b/lua/nvimx/resolve.lua @@ -3,7 +3,7 @@ -- Usage: -- nvim -l resolve.lua \ -- [--prev ] [--lock ] [--lazy ] \ --- [--update []]... [--update-plan ] [--import-lazy-lock ] +-- [--update []]... [--update-plan ] [--import-lazy-lock ] [--offline] -- -- Converts raw-spec.json into the plugins.json schema, merging with the previous lock so that -- refs already decided stay decided (--prev) and `pin = true` plugins get frozen onto the rev @@ -18,6 +18,8 @@ -- at all are seeded -- an existing --prev entry always wins. The seed is written to `resolvedRef` -- only, never to `commit` or `branch`: those are part of the spec identity, so writing there -- would make the next plain lock discard the seed as a spec change. +-- --offline skips version resolution (no git ls-remote); a plugin that needs one keeps +-- resolvedRef = null. Used by nvimx-lock --check, whose comparison ignores resolvedRef (#78). local function fail(msg) io.stderr:write("[nvimx] resolve failed: " .. tostring(msg) .. "\n") @@ -28,7 +30,7 @@ local function usage() io.stderr:write( "usage: nvim -l resolve.lua [--prev ] " .. "[--lock ] [--lazy ] [--update []]... " - .. "[--update-plan ] [--import-lazy-lock ]\n" + .. "[--update-plan ] [--import-lazy-lock ] [--offline]\n" ) os.exit(2) end @@ -38,6 +40,7 @@ end -- the two positional paths. local raw_path, out_path, prev_path, lock_path, lazy_path, update_plan_path, import_path local update_all = false +local offline = false local update_names = {} do local saw_bare_update = false @@ -78,6 +81,9 @@ do saw_bare_update = true i = i + 1 end + elseif a == "--offline" then + offline = true + i = i + 1 elseif a:sub(1, 2) == "--" then io.stderr:write(("[nvimx] resolve: unknown option %s\n"):format(a)) usage() @@ -1113,6 +1119,11 @@ if #pending > 0 then end end pending = syntax_ok + -- #78: --offline runs the network-free grammar validation above but never reaches the + -- ls-remote loop below (emptying pending also skips its progress line). + if offline then + pending = {} + end table.sort(pending, function(a, b) return a.name < b.name From 1b9b9506b827b931f5c0f85a626e083741c70497 Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:14:11 +0900 Subject: [PATCH 2/5] feat(lock): add --check to detect a stale lock offline Co-Authored-By: Claude Opus 5.5 --- README.md | 36 +++++ lua/nvimx/check.lua | 251 +++++++++++++++++++++++++++++ nix/lib/lock-app.nix | 56 ++++++- tests/checks/default.nix | 1 + tests/checks/lock-check.nix | 38 +++++ tests/checks/scripts/lock-check.sh | 221 +++++++++++++++++++++++++ 6 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 lua/nvimx/check.lua create mode 100644 tests/checks/lock-check.nix create mode 100644 tests/checks/scripts/lock-check.sh diff --git a/README.md b/README.md index 2940a51..a81f4ae 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,42 @@ otherwise. For example, a PR body table: jq -r '"| Plugin | Old | New |\n|---|---|---|", (.updated[] | "| \(.name) | \(.from[0:7] // "-") | \(if .compareUrl then "[\(.to[0:7])](\(.compareUrl))" else .to[0:7] end) |")' summary.json ``` +### Checking the lock in CI or a pre-commit hook + +`nvimx-lock --check` answers "would a plain `nvimx-lock` change what the committed lock says about +the spec?" without writing anything, running `nix`, or touching the network: + +```console +$ nvimx-lock --config ./nvim --out ./nvim/nvimx-lock --check +nvimx-lock: extracting spec from /path/nvim +nvimx-lock: checking /path/nvim/nvimx-lock against the spec +[nvimx] lock is stale: + missing from lock: oil-git-status.nvim + no longer in spec: telescope.nvim + spec changed: blink.cmp (version "1.*" -> "2.*") + not in flake.lock: baz.nvim + flake.lock only: stray-input +nvimx-lock: run nvimx-lock without --check to refresh /path/nvim/nvimx-lock +``` + +Exit status 0 means the lock is up to date (last line `nvimx-lock: lock is up to date`), 1 means it +is stale (including a missing lock), and 2 is a usage error; `--check` cannot be combined with +`--update`, `--import-lazy-lock` or `--summary-json`. An extraction or resolve failure also exits non-zero (1 for +most), but ends with the usual `nvimx-lock: failed (exit N)` line instead of the refresh hint. + +It compares everything the spec decides (added, removed and changed plugins, including `version`, +`branch`, `tag`, `commit`, `pin`, `build`, dependencies, the source URL, and local versus remote) +and that `flake.lock` has exactly the inputs `plugins.json` names. It does not compare the resolved +revs: a `version` range is not checked against remote tags (its syntax is), so a valid range that +matches no tag passes `--check` and fails the real lock; upstream moving is not detected; and +`flake.lock` revs are not verified. + +As a pre-commit hook, `nvimx-lock --config ./nvim --out ./nvim/nvimx-lock --check || { nvimx-lock +--config ./nvim --out ./nvim/nvimx-lock && git add ./nvim/nvimx-lock; }` refreshes the lock only +when needed. Like every `nvimx-lock`, it reads the working tree, not the index. In CI, run +`nix run github:myuron/nvimx#lock -- --config ./nvim --out ./nvim/nvimx-lock --check`, preferably +the same nvimx revision your dotfiles pin: a different one may report schema differences as stale. + ## Migrating from lazy.nvim If you already run lazy.nvim, you have a `lazy-lock.json` recording the exact commit of every diff --git a/lua/nvimx/check.lua b/lua/nvimx/check.lua new file mode 100644 index 0000000..2e54e90 --- /dev/null +++ b/lua/nvimx/check.lua @@ -0,0 +1,251 @@ +-- nvimx lock staleness check (#78) +-- +-- Usage: nvim -l check.lua +-- +-- Compares the plugins.json a lock would write *now* (resolve.lua --offline, run on the current +-- spec by nvimx-lock --check) with the committed lock, and prints one line per difference on +-- stdout. Empty stdout means the lock is up to date. +-- +-- Staleness is signalled by stdout, never by the exit code: this script exits 0 both when the lock +-- is fresh and when it is stale, so an uncaught Lua error (exit 1) or a failed read (exit 2) can +-- never masquerade as either answer -- lock-app treats any non-zero exit as a failure. +-- +-- Deliberately ignored, because a relock would change them without the spec having moved: +-- * resolvedRef -- the only network/lock-derived field (semver result, pin freeze, import seed, +-- --update). Every spec edit that would change it also changes a compared field (version, +-- branch, tag, commit, pin, source), so ignoring it loses nothing, and the candidate is built +-- offline and has none of them anyway. +-- * warnings -- diagnostics, some of which depend on resolvedRef. +-- * flake.nix -- the build never reads it, and a formatter version bump would flag it falsely. +-- flake.lock is only checked for its *set* of root inputs, not for revs. + +local candidate_path, committed_path, flake_lock_path = arg[1], arg[2], arg[3] +assert( + candidate_path and committed_path and flake_lock_path, + "usage: nvim -l check.lua " +) + +local function die(msg) + io.stderr:write("[nvimx] check failed: " .. msg .. "\n") + os.exit(2) +end + +local function read_json(path, what) + local f = io.open(path, "r") + if not f then + die(("cannot read %s (%s)"):format(what, path)) + end + local text = f:read("*a") + f:close() + local ok, decoded = pcall(vim.json.decode, text) + if not ok or type(decoded) ~= "table" then + die( + ("%s is not valid JSON (%s): %s. Fix the file, or delete it and run nvimx-lock again -- deleting it loses the pinned revs."):format( + what, + path, + tostring(decoded) + ) + ) + end + return decoded +end + +local candidate = read_json(candidate_path, "the candidate plugins.json") +local committed = read_json(committed_path, "the existing plugins.json") +local flake_lock = read_json(flake_lock_path, "the existing flake.lock") + +if committed.schemaVersion ~= 1 then + die( + ("the existing plugins.json (%s) has schemaVersion %s, but this nvimx only understands 1. "):format( + committed_path, + tostring(committed.schemaVersion) + ) .. "Upgrade nvimx, or delete the file and run nvimx-lock again -- deleting it loses the pinned revs." + ) +end + +local IGNORED = { resolvedRef = true } + +local function is_null(v) + return v == nil or v == vim.NIL +end + +-- nil and vim.NIL are the same thing (a key one side lacks reads as null on the other), and the +-- key union makes the comparison symmetric. [] and {} both decode to an empty table. +local function deep_equal(a, b) + if is_null(a) or is_null(b) then + return is_null(a) and is_null(b) + end + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b + end + local keys = {} + for k in pairs(a) do + keys[k] = true + end + for k in pairs(b) do + keys[k] = true + end + for k in pairs(keys) do + if not deep_equal(a[k], b[k]) then + return false + end + end + return true +end + +local function render(v) + if is_null(v) then + return "null" + end + return vim.json.encode(v) +end + +-- The changed fields of two remote entries, as "key old -> new" (tables as "key changed"), sorted +-- by key. Empty when the entries agree. +local function diff_entries(old, new) + local keys = {} + for k in pairs(old) do + keys[#keys + 1] = k + end + for k in pairs(new) do + if old[k] == nil then + keys[#keys + 1] = k + end + end + table.sort(keys) + local parts = {} + for _, k in ipairs(keys) do + if not IGNORED[k] and not deep_equal(old[k], new[k]) then + if type(old[k]) == "table" or type(new[k]) == "table" then + parts[#parts + 1] = k .. " changed" + else + parts[#parts + 1] = ("%s %s -> %s"):format(k, render(old[k]), render(new[k])) + end + end + end + return parts +end + +-- name -> entry (remote) or false (local). `lazyNvim` is deliberately not part of this map: it +-- lives outside plugins/localPlugins, and a dev/dir lazy.nvim puts "lazy.nvim" into localPlugins +-- while lazyNvim stays the synthetic literal (#49), so it is compared separately below. +local function name_map(lock) + local map = {} + for name, entry in pairs(type(lock.plugins) == "table" and lock.plugins or {}) do + map[name] = entry + end + for name in pairs(type(lock.localPlugins) == "table" and lock.localPlugins or {}) do + map[name] = false + end + return map +end + +local function sorted_keys(t) + local keys = {} + for k in pairs(t) do + keys[#keys + 1] = k + end + table.sort(keys) + return keys +end + +local want, have = name_map(candidate), name_map(committed) +local missing, removed, changed, no_input, stray = {}, {}, {}, {}, {} + +for _, name in ipairs(sorted_keys(want)) do + if have[name] == nil then + missing[#missing + 1] = name + end +end +for _, name in ipairs(sorted_keys(have)) do + if want[name] == nil then + removed[#removed + 1] = name + end +end +for _, name in ipairs(sorted_keys(want)) do + local new, old = want[name], have[name] + if old ~= nil then + if new == false and old ~= false then + changed[#changed + 1] = { name, "now a local plugin" } + elseif new ~= false and old == false then + changed[#changed + 1] = { name, "no longer a local plugin" } + elseif new ~= false then + local parts = diff_entries(old, new) + if #parts > 0 then + changed[#changed + 1] = { name, table.concat(parts, ", ") } + end + end + end +end + +-- lazy.nvim's own entry, on its own footing (see name_map). A committed lazyNvim without a +-- `synthetic` key predates #49, which could only ever write the synthetic literal, and the build +-- reads both identically -- so it is read as synthetic rather than flagged for lacking the key. +local old_lazy = type(committed.lazyNvim) == "table" and committed.lazyNvim or {} +local new_lazy = type(candidate.lazyNvim) == "table" and candidate.lazyNvim or {} +if is_null(old_lazy.synthetic) then + old_lazy.synthetic = true +end +do + local parts = diff_entries(old_lazy, new_lazy) + if #parts > 0 then + changed[#changed + 1] = { "lazy.nvim", table.concat(parts, ", ") } + table.sort(changed, function(a, b) + return a[1] < b[1] + end) + end +end + +-- flake.lock: only the set of root inputs, against the committed plugins.json's inputNames. +local root_inputs = {} +do + local nodes = type(flake_lock.nodes) == "table" and flake_lock.nodes or {} + local root = nodes[flake_lock.root or "root"] + root = type(root) == "table" and root or {} + for input in pairs(type(root.inputs) == "table" and root.inputs or {}) do + root_inputs[input] = true + end +end +local claimed = {} +local function claim(name, entry) + if type(entry) == "table" and type(entry.inputName) == "string" then + claimed[entry.inputName] = true + if not root_inputs[entry.inputName] then + no_input[#no_input + 1] = name + end + end +end +for _, name in ipairs(sorted_keys(type(committed.plugins) == "table" and committed.plugins or {})) do + claim(name, committed.plugins[name]) +end +claim("lazy.nvim", committed.lazyNvim) +table.sort(no_input) +for _, input in ipairs(sorted_keys(root_inputs)) do + if not claimed[input] then + stray[#stray + 1] = input + end +end + +local out = {} +local function emit(label, items, text) + for _, item in ipairs(items) do + out[#out + 1] = (" %-18s %s"):format(label, text(item)) + end +end +local function name_only(item) + return item +end +emit("missing from lock:", missing, name_only) +emit("no longer in spec:", removed, name_only) +emit("spec changed:", changed, function(item) + return ("%s (%s)"):format(item[1], item[2]) +end) +emit("not in flake.lock:", no_input, name_only) +emit("flake.lock only:", stray, name_only) + +if #out > 0 then + io.stdout:write("[nvimx] lock is stale:\n" .. table.concat(out, "\n") .. "\n") +end diff --git a/nix/lib/lock-app.nix b/nix/lib/lock-app.nix index 28e94a6..2a10448 100644 --- a/nix/lib/lock-app.nix +++ b/nix/lib/lock-app.nix @@ -1,5 +1,5 @@ # nvimx-lock: a script that runs extract → resolve → flake generation → nix flake lock in one go. -# Locking is an online operation (the build is fully pure and offline). +# Locking is an online operation (the build is fully pure and offline); --check is the offline exception (#78). { pkgs, lazyNvimSeed }: let luaDir = ../../lua/nvimx; @@ -22,7 +22,7 @@ pkgs.writeShellApplication { ]; text = '' usage() { - echo "usage: nvimx-lock --config --out [--update [name...] | --import-lazy-lock [path]] [--summary-json ]" >&2 + echo "usage: nvimx-lock --config --out [--update [name...] | --import-lazy-lock [path] | --check] [--summary-json ]" >&2 exit 2 } @@ -34,6 +34,7 @@ pkgs.writeShellApplication { import_mode=0 import_path="" summary_json="" + check_mode=0 while [ $# -gt 0 ]; do case "$1" in --config) @@ -79,6 +80,10 @@ pkgs.writeShellApplication { shift fi ;; + --check) + check_mode=1 + shift + ;; *) usage ;; @@ -103,6 +108,12 @@ pkgs.writeShellApplication { echo "nvimx-lock: --import-lazy-lock cannot be combined with --update; run them as two separate locks" >&2 usage fi + # --check (#78) only answers "is the lock stale?", so combining it with a flag whose whole + # point is to rewrite the lock is a contradiction, not a mode to guess at. + if [ "$check_mode" -eq 1 ] && { [ "$update_mode" -eq 1 ] || [ "$import_mode" -eq 1 ] || [ -n "$summary_json" ]; }; then + echo "nvimx-lock: --check never writes the lock, so it cannot be combined with --update, --import-lazy-lock or --summary-json" >&2 + usage + fi [ -n "$config" ] && [ -n "$out" ] || usage # --summary-json (#77): the path is only inspected here, nothing is created (no mkdir -p: the # parent has to exist, which also rules out a path inside a not-yet-created --out). An `if`, @@ -124,6 +135,17 @@ pkgs.writeShellApplication { # this script; only the pure argument errors above may precede it. rm -f -- "$summary_json" fi + # --check on a directory with no lock: not up to date, so exit 1 (`--check || nvimx-lock` + # then bootstraps a first lock). Checked against the raw --out, before realpath/mkdir below, + # so that --check never creates the directory; the final line therefore shows --out as given. + if [ "$check_mode" -eq 1 ]; then + if [ ! -f "$out/plugins.json" ] || [ ! -f "$out/flake.lock" ]; then + echo "[nvimx] lock is stale:" >&2 + echo " no lock in $out (plugins.json and flake.lock are both needed)" >&2 + echo "nvimx-lock: run nvimx-lock without --check to refresh $out" >&2 + exit 1 + fi + fi # --update is a lock's forward motion, not its beginning: mixing it with a first-ever lock # would leave resolve.lua with no previous decisions to force away from in the first place. # Checked against the raw --out argument, before `mkdir -p "$out"` / `realpath "$out"` below, @@ -279,6 +301,36 @@ pkgs.writeShellApplication { echo "nvimx-lock: extracting spec from $config" >&2 extract_spec + # --check (#78): resolve the spec offline into a candidate plugins.json in the sandbox and + # compare it with the committed lock. Nothing here touches $out, runs nix, or reaches the + # network: --offline skips resolve.lua's git ls-remote loop, and check.lua ignores the + # resolvedRef it would have produced. + # The log is check.log, not resolve.log, so the trap's flush_logs (which also runs on exit 0) + # does not dump resolve warnings on a clean check; they only surface on failure. A stale + # result clears the trap so it does not print "failed (exit 1)" -- this avoids touching + # cleanup(). A failing check.lua trips errexit, so the trap reports it as a failure. + if [ "$check_mode" -eq 1 ]; then + echo "nvimx-lock: checking $out against the spec" >&2 + rc=0 + nvim -l "${luaDir}/resolve.lua" "$sandbox/raw-spec.json" "$sandbox/plugins.json" \ + --lazy "$seed" --offline 2> "$sandbox/check.log" || rc=$? + if [ "$rc" -ne 0 ]; then + cat "$sandbox/check.log" >&2 + exit "$rc" + fi + nvim -l "${luaDir}/check.lua" "$sandbox/plugins.json" "$out/plugins.json" "$out/flake.lock" \ + > "$sandbox/check.txt" + if [ -s "$sandbox/check.txt" ]; then + cat "$sandbox/check.txt" >&2 + echo "nvimx-lock: run nvimx-lock without --check to refresh $out" >&2 + trap - EXIT + rm -rf "$sandbox" + exit 1 + fi + echo "nvimx-lock: lock is up to date" >&2 + exit 0 + fi + echo "nvimx-lock: resolving plugins" >&2 # Whatever the previous lock decided is an input to this one: resolve.lua carries already # resolved refs forward and freezes `pin = true` plugins onto the rev in flake.lock. Deciding diff --git a/tests/checks/default.nix b/tests/checks/default.nix index 9bdfba8..0c970be 100644 --- a/tests/checks/default.nix +++ b/tests/checks/default.nix @@ -48,6 +48,7 @@ pkgs.lib.genAttrs [ "lock-app" "lock-untracked-config" "lock-lazy-seed" + "lock-check" "resolve-sources" "resolve-golden" "genflake-golden" diff --git a/tests/checks/lock-check.nix b/tests/checks/lock-check.nix new file mode 100644 index 0000000..b6a42eb --- /dev/null +++ b/tests/checks/lock-check.nix @@ -0,0 +1,38 @@ +# nvimx-lock --check (#78), end to end: that it spots every kind of stale lock, prints the report +# the README promises, and does so without writing, without nix and without the network. Recursive +# nix cannot run in a build, so `nix` is a stub as in checks.lock-app -- but a different one: it +# *generates* flake.lock from the flake.nix in its cwd (input names from the nixfmt'd lines, a +# locked.rev derived from the name so `pin = true` can freeze). checks.lock-app's fixed +# flake.lock would be rejected by --check's flake.lock input-set rule for any other plugin set. +# The stub also proves "--check never runs nix": it records argv, which has to stay empty. The +# build sandbox has no network, which together with deleting the `version` remote proves it never +# runs git ls-remote either (a non-offline resolve would fail there). The nixfmt layout the stub +# parses is the one checks.genflake-golden pins, so a formatter change fails loudly here. +{ + pkgs, + nvimxLib, + mkTagRepoSh, + ... +}: +let + stubNix = pkgs.writeShellScriptBin "nix" '' + printf '%s\n' "$*" >> "$NVIMX_STUB_ARGV" + for n in $(${pkgs.gnused}/bin/sed -n 's/^ \(.*\) = {$/\1/p' flake.nix); do + echo "$n $(echo "$n" | ${pkgs.coreutils}/bin/sha1sum | ${pkgs.coreutils}/bin/cut -c1-40)" + done | ${pkgs.jq}/bin/jq -R -s ' + split("\n") | map(select(. != "") | split(" ")) as $p + | { version: 7, root: "root", + nodes: ({ root: { inputs: ($p | map({ key: .[0], value: .[0] }) | from_entries) } } + + ($p | map({ key: .[0], value: { flake: false, locked: { rev: .[1] } } }) | from_entries)) } + ' > flake.lock + chmod u+w flake.lock + ''; +in +pkgs.runCommand "lock-check" { + nativeBuildInputs = [ + pkgs.git + pkgs.jq + nvimxLib.lockApp + stubNix + ]; +} (mkTagRepoSh + "source ${./scripts/lock-check.sh}") diff --git a/tests/checks/scripts/lock-check.sh b/tests/checks/scripts/lock-check.sh new file mode 100644 index 0000000..e91384f --- /dev/null +++ b/tests/checks/scripts/lock-check.sh @@ -0,0 +1,221 @@ +# shellcheck shell=bash disable=SC2154,SC2016 +# Body of checks.lock-check, sourced by the runCommand in ../lock-check.nix (mkrepo comes from +# the mkTagRepoSh prelude). Helpers mirror checks.lock-app's: its own TMPDIR so that a sandbox +# nvimx-lock leaves behind can be told apart, and a stub nix that records argv. +export HOME=$TMPDIR +here=$(realpath .) +mkdir locktmp +export TMPDIR=$here/locktmp +no_leftover() { + if [ -n "$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -name 'tmp.*')" ]; then + echo "nvimx-lock left its sandbox behind in $TMPDIR" >&2 + exit 1 + fi +} +export NVIMX_STUB_ARGV=$here/argv +: >argv +# `! grep -q` would be vacuous: a `!`-negated command never trips errexit. +absent() { # + if grep -q -- "$1" "$2"; then + echo "unexpected \"$1\" in $2" >&2 + exit 1 + fi +} +fail_line() { # + echo "nvimx-lock: failed (exit $1) -- look for the error above; it may sit above the import report and warnings" +} +# mkcfg : a config whose lazy spec is read from stdin. +mkcfg() { + mkdir -p "$1" + { + echo 'vim.opt.rtp:prepend(vim.fn.stdpath("data") .. "/lazy/lazy.nvim")' + echo 'require("lazy").setup({' + cat + echo '})' + } >"$1/init.lua" +} +base_spec() { + cat < : runs --check, leaves rc, .out and .log behind. +check() { + : >argv + rc=0 + nvimx-lock --config "$1" --out "$2" --check >"$3.out" 2>"$3.log" || rc=$? + [ ! -s "$3.out" ] + [ ! -s argv ] + no_leftover +} +# stale_block : the report out of .log, header through the last line. +stale_block() { + sed -n '/^\[nvimx\] lock is stale:$/,$p' "$1.log" +} +last_line() { tail -n 1 "$1.log"; } + +mkrepo "$TMPDIR/remote" v1.0.0 v1.1.0 +base_spec | mkcfg cfg + +# 1. A fresh lock passes: exit 0, the documented last line, nothing written, no nix call. The +# lock really holds a frozen pin and a resolved tag, so this also shows resolvedRef is ignored +# rather than merely null on both sides. +nvimx-lock --config cfg --out lock 2>lock.log +jq -e '.plugins["tokyonight.nvim"].resolvedRef | test("^[0-9a-f]{40}$")' lock/plugins.json >/dev/null +jq -e '.plugins["tagged.nvim"].resolvedRef | startswith("refs/tags/")' lock/plugins.json >/dev/null +rm -rf lock.snap && cp -a lock lock.snap +check cfg lock c1 +[ "$rc" -eq 0 ] +[ "$(last_line c1)" = "nvimx-lock: lock is up to date" ] +absent 'lock is stale' c1.log +diff -r lock lock.snap + +# 2. Offline: with the `version` remote gone, a non-offline resolve would fail in git ls-remote. +rm -rf "$TMPDIR/remote" +check cfg lock c2 +[ "$rc" -eq 0 ] +[ "$(last_line c2)" = "nvimx-lock: lock is up to date" ] + +# 3. Right after --update. +mkrepo "$TMPDIR/remote" v1.0.0 v1.1.0 +nvimx-lock --config cfg --out lock --update 2>update.log +rm -rf lock.snap && cp -a lock lock.snap +check cfg lock c3 +[ "$rc" -eq 0 ] +[ "$(last_line c3)" = "nvimx-lock: lock is up to date" ] +diff -r lock lock.snap + +# 4. Added / removed / changed. One run per edit, asserting exactly its own line, so one rule +# cannot mask another; then all edits at once for the full report. +single() { # + base_spec | sed "$3" | mkcfg "cfg-$1" + check "cfg-$1" lock "s-$1" + [ "$rc" -eq 1 ] + printf '%s\n' '[nvimx] lock is stale:' "$2" "nvimx-lock: run nvimx-lock without --check to refresh $here/lock" >"s-$1.want" + stale_block "s-$1" | diff -u "s-$1.want" - + absent 'nvimx-lock: failed' "s-$1.log" + diff -r lock lock.snap +} +single add ' missing from lock: oil.nvim' '$a{ "stevearc/oil.nvim" },' +single del ' no longer in spec: plenary.nvim' '/plenary/d' +single ver ' spec changed: tagged.nvim (version "^1" -> "^2")' 's/"\^1"/"^2"/' +single build ' spec changed: tokyonight.nvim (build changed)' 's/pin = true/pin = true, build = ":Foo"/' +single remote ' spec changed: localdir.nvim (no longer a local plugin)' 's|{ dir = .*|{ "x/localdir.nvim" },|' +single local ' spec changed: plenary.nvim (now a local plugin)' 's|{ "nvim-lua/plenary.nvim" }|{ dir = "/nvimx-fixture/plenary", name = "plenary.nvim" }|' + +base_spec | sed -e '$a{ "stevearc/oil.nvim" },' -e '/plenary/d' -e 's/"\^1"/"^2"/' \ + -e 's/pin = true/pin = true, build = ":Foo"/' -e 's|{ dir = .*|{ "x/localdir.nvim" },|' | mkcfg cfg-all +check cfg-all lock c4 +[ "$rc" -eq 1 ] +cat >c4.want < "^2") + spec changed: tokyonight.nvim (build changed) +nvimx-lock: run nvimx-lock without --check to refresh $here/lock +WANT +stale_block c4 | diff -u c4.want - +absent 'nvimx-lock: failed' c4.log +diff -r lock lock.snap + +# 5. plugins.json and flake.lock out of sync: an input the plugins.json needs is gone, and one +# nothing claims is present. +cp -a lock lock5 +jq 'del(.nodes.root.inputs["tokyonight-nvim"]) | .nodes.root.inputs.stray = "stray"' lock/flake.lock >lock5/flake.lock +check cfg lock5 c5 +[ "$rc" -eq 1 ] +cat >c5.want <c6.want <<'WANT' +[nvimx] lock is stale: + no lock in nolock (plugins.json and flake.lock are both needed) +nvimx-lock: run nvimx-lock without --check to refresh nolock +WANT +stale_block c6 | diff -u c6.want - +[ ! -e nolock ] + +# 7. --check never writes, so it refuses the flags whose point is to. +: >argv +for flags in "--update" "--import-lazy-lock x" "--summary-json sj.json"; do + rc=0 + # shellcheck disable=SC2086 + nvimx-lock --config cfg --out never --check $flags 2>c7.log || rc=$? + [ "$rc" -eq 2 ] + grep -q 'cannot be combined' c7.log + absent 'nvimx-lock: failed' c7.log + [ ! -e never ] + [ ! -e sj.json ] +done +[ ! -s argv ] +no_leftover + +# 8. Errors are not "stale": they keep the failure line and never print the report header. +cp -a lock lock8 +jq '.schemaVersion = 2' lock/plugins.json >lock8/plugins.json +check cfg lock8 c8a +[ "$rc" -ne 0 ] +[ "$(last_line c8a)" = "$(fail_line "$rc")" ] +absent 'lock is stale' c8a.log +mkdir cfg-nosetup +echo '-- no lazy.setup here' >cfg-nosetup/init.lua +check cfg-nosetup lock c8b +[ "$rc" -eq 1 ] +[ "$(last_line c8b)" = "$(fail_line 1)" ] +absent 'lock is stale' c8b.log +# A number is not a version constraint; the grammar check still runs under --offline. +base_spec | sed 's/"\^1"/1.2/' | mkcfg cfg-badver +check cfg-badver lock c8c +[ "$rc" -ne 0 ] +[ "$(last_line c8c)" = "$(fail_line "$rc")" ] +absent 'lock is stale' c8c.log + +# 9. lazy.nvim's own entry (#49) lives outside plugins/localPlugins, so it must not be part of the +# name map the other rules use. +# a. Locked without it, then the spec adds it: exactly one line, with the absent synthetic-literal +# keys reading as null. +base_spec | mkcfg cfg9 +nvimx-lock --config cfg9 --out lock9a 2>/dev/null +{ + base_spec + echo '{ "folke/lazy.nvim", tag = "v11.0.0" },' +} | mkcfg cfg9a +check cfg9a lock9a c9a +[ "$rc" -eq 1 ] +printf '%s\n' '[nvimx] lock is stale:' \ + ' spec changed: lazy.nvim (synthetic true -> false, tag null -> "v11.0.0")' \ + "nvimx-lock: run nvimx-lock without --check to refresh $here/lock9a" >c9a.want +stale_block c9a | diff -u c9a.want - +# b. A spec lazy.nvim that is locked is fresh (no build/dependencies asymmetry for the self-entry). +nvimx-lock --config cfg9a --out lock9b 2>/dev/null +check cfg9a lock9b c9b +[ "$rc" -eq 0 ] +# c. A local lazy.nvim: localPlugins["lazy.nvim"] and the synthetic lazyNvim coexist. +{ + base_spec + echo '{ "folke/lazy.nvim", dir = "/nvimx-fixture/lazy.nvim" },' +} | mkcfg cfg9c +nvimx-lock --config cfg9c --out lock9c 2>/dev/null +check cfg9c lock9c c9c +[ "$rc" -eq 0 ] +# d. A lock written before #49 has no `synthetic` key on lazyNvim; the build reads it the same. +cp -a lock lock9d +jq 'del(.lazyNvim.synthetic)' lock/plugins.json >lock9d/plugins.json +check cfg lock9d c9d +[ "$rc" -eq 0 ] + +touch "$out" From fdde40d551e2446756796410e7a140704483c9b0 Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:21:01 +0900 Subject: [PATCH 3/5] fix(lock): drop pre-#49 synthetic normalisation from --check Co-Authored-By: Claude Opus 5.5 --- lua/nvimx/check.lua | 7 +------ tests/checks/scripts/lock-check.sh | 5 ----- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/lua/nvimx/check.lua b/lua/nvimx/check.lua index 2e54e90..5c3a226 100644 --- a/lua/nvimx/check.lua +++ b/lua/nvimx/check.lua @@ -181,14 +181,9 @@ for _, name in ipairs(sorted_keys(want)) do end end --- lazy.nvim's own entry, on its own footing (see name_map). A committed lazyNvim without a --- `synthetic` key predates #49, which could only ever write the synthetic literal, and the build --- reads both identically -- so it is read as synthetic rather than flagged for lacking the key. +-- lazy.nvim's own entry, on its own footing (see name_map). local old_lazy = type(committed.lazyNvim) == "table" and committed.lazyNvim or {} local new_lazy = type(candidate.lazyNvim) == "table" and candidate.lazyNvim or {} -if is_null(old_lazy.synthetic) then - old_lazy.synthetic = true -end do local parts = diff_entries(old_lazy, new_lazy) if #parts > 0 then diff --git a/tests/checks/scripts/lock-check.sh b/tests/checks/scripts/lock-check.sh index e91384f..6aa224a 100644 --- a/tests/checks/scripts/lock-check.sh +++ b/tests/checks/scripts/lock-check.sh @@ -212,10 +212,5 @@ check cfg9a lock9b c9b nvimx-lock --config cfg9c --out lock9c 2>/dev/null check cfg9c lock9c c9c [ "$rc" -eq 0 ] -# d. A lock written before #49 has no `synthetic` key on lazyNvim; the build reads it the same. -cp -a lock lock9d -jq 'del(.lazyNvim.synthetic)' lock/plugins.json >lock9d/plugins.json -check cfg lock9d c9d -[ "$rc" -eq 0 ] touch "$out" From 16ca7af1a21407942a5ee5de9156c370c977e72d Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:24:10 +0900 Subject: [PATCH 4/5] feat(lock): warn about untracked config files in --check Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + nix/lib/lock-app.nix | 92 ++++++++++++++++-------------- tests/checks/scripts/lock-check.sh | 26 +++++++++ 3 files changed, 79 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index a81f4ae..edee23c 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,9 @@ is stale (including a missing lock), and 2 is a usage error; `--check` cannot be `--update`, `--import-lazy-lock` or `--summary-json`. An extraction or resolve failure also exits non-zero (1 for most), but ends with the usual `nvimx-lock: failed (exit N)` line instead of the refresh hint. +Like a plain lock, `--check` also warns (on stderr, without changing the exit status) about files in +a git-tracked config that git does not track, since a flake-deployed config would lack them. + It compares everything the spec decides (added, removed and changed plugins, including `version`, `branch`, `tag`, `commit`, `pin`, `build`, dependencies, the source URL, and local versus remote) and that `flake.lock` has exactly the inputs `plugins.json` names. It does not compare the resolved diff --git a/nix/lib/lock-app.nix b/nix/lib/lock-app.nix index 2a10448..a8a2c0f 100644 --- a/nix/lib/lock-app.nix +++ b/nix/lib/lock-app.nix @@ -301,6 +301,51 @@ pkgs.writeShellApplication { echo "nvimx-lock: extracting spec from $config" >&2 extract_spec + # Config files git does not know about (#33). Extraction above read the work tree, so an + # untracked spec file IS locked; the gap is at deployment: home-manager deploys configDir + # from the flake source, and a git flake contains only indexed files (measured: new-but-staged + # files are visible, untracked and gitignored ones are not). Files that are tracked but + # modified are NOT a hazard -- nix reads the work tree for them ("Git tree is dirty") -- so + # only `--others` is listed. Same two-stage git detection and read-only stance as #71's probe + # below. Everything is wrapped so that no git hiccup can fail a finished lock. + # The lock dir is dropped from the list when it sits inside the config (#71's note covers it). + # Edge case not handled: --out equal to --config would list the three lock files. + # Submodules and nested repos are out of scope: ls-files --others does not descend into them. + warn_untracked_config() { + if [ "$(git -C "$config" rev-parse --is-inside-work-tree 2> /dev/null)" = "true" ]; then + mapfile -d "" -t untracked_all < <(git -C "$config" ls-files -z --others --exclude-standard 2> /dev/null || true) + lock_rel="" + case "$out" in + "$config"/*) lock_rel=''${out#"$config"/} ;; + esac + untracked=() + for f in ''${untracked_all[@]+"''${untracked_all[@]}"}; do + if [ -n "$lock_rel" ]; then + case "$f" in + "$lock_rel"/*) continue ;; + esac + fi + untracked+=("$f") + done + if [ "''${#untracked[@]}" -gt 0 ]; then + shown=("''${untracked[@]:0:20}") + echo "nvimx-lock: warning: ''${#untracked[@]} file(s) under $config are not tracked by git. nvimx-lock read them from your work tree, but a flake only sees git-tracked files, so a flake-deployed config (manageConfig = true) will lack them:" >&2 + for f in "''${shown[@]}"; do + echo "nvimx-lock: warning: $f" >&2 + done + echo "nvimx-lock: warning: track them, then commit and switch (no need to re-run nvimx-lock):" >&2 + if [ "''${#untracked[@]}" -gt 20 ]; then + echo "nvimx-lock: warning: ... and $((''${#untracked[@]} - 20)) more (git -C \"$config\" status --untracked-files=all)" >&2 + echo "nvimx-lock: warning: git -C \"$config\" add -- ." >&2 + else + printf 'nvimx-lock: warning: git -C "%s" add --%s\n' "$config" "$(printf ' %q' "''${shown[@]}")" >&2 + fi + fi + else + echo "nvimx-lock: $config is not inside a git work tree; skipped the check for untracked config files" >&2 + fi + } + # --check (#78): resolve the spec offline into a candidate plugins.json in the sandbox and # compare it with the committed lock. Nothing here touches $out, runs nix, or reaches the # network: --offline skips resolve.lua's git ls-remote loop, and check.lua ignores the @@ -320,6 +365,10 @@ pkgs.writeShellApplication { fi nvim -l "${luaDir}/check.lua" "$sandbox/plugins.json" "$out/plugins.json" "$out/flake.lock" \ > "$sandbox/check.txt" + # Warnings only (#33): a hook running --check should still hear about a spec file git does + # not know about. Never changes the exit code or the report; it comes before both so the + # last line stays the verdict. + warn_untracked_config if [ -s "$sandbox/check.txt" ]; then cat "$sandbox/check.txt" >&2 echo "nvimx-lock: run nvimx-lock without --check to refresh $out" >&2 @@ -508,48 +557,7 @@ pkgs.writeShellApplication { ''${update_names[@]+"''${update_names[@]}"} || summary_rc=$? fi - # Config files git does not know about (#33). Extraction above read the work tree, so an - # untracked spec file IS locked; the gap is at deployment: home-manager deploys configDir - # from the flake source, and a git flake contains only indexed files (measured: new-but-staged - # files are visible, untracked and gitignored ones are not). Files that are tracked but - # modified are NOT a hazard -- nix reads the work tree for them ("Git tree is dirty") -- so - # only `--others` is listed. Same two-stage git detection and read-only stance as #71's probe - # below. Everything is wrapped so that no git hiccup can fail a finished lock. - # The lock dir is dropped from the list when it sits inside the config (#71's note covers it). - # Edge case not handled: --out equal to --config would list the three lock files. - # Submodules and nested repos are out of scope: ls-files --others does not descend into them. - if [ "$(git -C "$config" rev-parse --is-inside-work-tree 2> /dev/null)" = "true" ]; then - mapfile -d "" -t untracked_all < <(git -C "$config" ls-files -z --others --exclude-standard 2> /dev/null || true) - lock_rel="" - case "$out" in - "$config"/*) lock_rel=''${out#"$config"/} ;; - esac - untracked=() - for f in ''${untracked_all[@]+"''${untracked_all[@]}"}; do - if [ -n "$lock_rel" ]; then - case "$f" in - "$lock_rel"/*) continue ;; - esac - fi - untracked+=("$f") - done - if [ "''${#untracked[@]}" -gt 0 ]; then - shown=("''${untracked[@]:0:20}") - echo "nvimx-lock: warning: ''${#untracked[@]} file(s) under $config are not tracked by git. nvimx-lock read them from your work tree, but a flake only sees git-tracked files, so a flake-deployed config (manageConfig = true) will lack them:" >&2 - for f in "''${shown[@]}"; do - echo "nvimx-lock: warning: $f" >&2 - done - echo "nvimx-lock: warning: track them, then commit and switch (no need to re-run nvimx-lock):" >&2 - if [ "''${#untracked[@]}" -gt 20 ]; then - echo "nvimx-lock: warning: ... and $((''${#untracked[@]} - 20)) more (git -C \"$config\" status --untracked-files=all)" >&2 - echo "nvimx-lock: warning: git -C \"$config\" add -- ." >&2 - else - printf 'nvimx-lock: warning: git -C "%s" add --%s\n' "$config" "$(printf ' %q' "''${shown[@]}")" >&2 - fi - fi - else - echo "nvimx-lock: $config is not inside a git work tree; skipped the check for untracked config files" >&2 - fi + warn_untracked_config echo "nvimx-lock: done. commit $out/{plugins.json,flake.nix,flake.lock}" >&2 # A flake only sees git-tracked files, and make-env.nix's hasLock cannot see an untracked diff --git a/tests/checks/scripts/lock-check.sh b/tests/checks/scripts/lock-check.sh index 6aa224a..12da84e 100644 --- a/tests/checks/scripts/lock-check.sh +++ b/tests/checks/scripts/lock-check.sh @@ -213,4 +213,30 @@ nvimx-lock --config cfg9c --out lock9c 2>/dev/null check cfg9c lock9c c9c [ "$rc" -eq 0 ] +# 10. Untracked spec files (#33, shared with the plain lock through one function): --check warns +# about them too -- a hook `--check || nvimx-lock` would otherwise pass silently -- but the +# warning is only a warning: exit code, empty stdout and the verdict as last line are unchanged. +git init -q -b main repo +base_spec | mkcfg repo/nvim +git -C repo add nvim/init.lua +git -C repo -c user.name=t -c user.email=t@t commit -q -m init +nvimx-lock --config repo/nvim --out lock10 2>/dev/null +echo notes >repo/nvim/notes.txt +check repo/nvim lock10 c10a +[ "$rc" -eq 0 ] +grep -q '^nvimx-lock: warning: 1 file(s) under .* are not tracked by git' c10a.log +grep -qxF 'nvimx-lock: warning: notes.txt' c10a.log +[ "$(last_line c10a)" = "nvimx-lock: lock is up to date" ] +# Stale and untracked at once: both are reported, the refresh hint still comes last. +echo '-- changed' >>repo/nvim/init.lua +sed -i 's/"\^1"/"^2"/' repo/nvim/init.lua +check repo/nvim lock10 c10b +[ "$rc" -eq 1 ] +grep -qxF 'nvimx-lock: warning: notes.txt' c10b.log +[ "$(last_line c10b)" = "nvimx-lock: run nvimx-lock without --check to refresh $here/lock10" ] +# Tracked, nothing to warn about. +git -C repo add nvim/notes.txt nvim/init.lua +check repo/nvim lock10 c10c +absent 'nvimx-lock: warning' c10c.log + touch "$out" From de8b3fbb4d81a1f9a458d44c522c6141cac90fb8 Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:39:56 +0900 Subject: [PATCH 5/5] feat(lock): make --check extract against nvimx's own lazy.nvim seed Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++++ nix/lib/lock-app.nix | 13 +++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index edee23c..43b66ca 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,10 @@ most), but ends with the usual `nvimx-lock: failed (exit N)` line instead of the Like a plain lock, `--check` also warns (on stderr, without changing the exit status) about files in a git-tracked config that git does not track, since a flake-deployed config would lack them. +`--check` extracts the spec against nvimx's bundled lazy.nvim rather than the one pinned in +`flake.lock` (finding that one needs `nix`), so a spec relying on a newer lazy.nvim feature could +extract differently than in a real lock. + It compares everything the spec decides (added, removed and changed plugins, including `version`, `branch`, `tag`, `commit`, `pin`, `build`, dependencies, the source URL, and local versus remote) and that `flake.lock` has exactly the inputs `plugins.json` names. It does not compare the resolved diff --git a/nix/lib/lock-app.nix b/nix/lib/lock-app.nix index a8a2c0f..992345c 100644 --- a/nix/lib/lock-app.nix +++ b/nix/lib/lock-app.nix @@ -273,8 +273,17 @@ pkgs.writeShellApplication { NVIMX_LOCK_DIR="$out" nix eval --raw --impure --expr \ 'import ${./locked-lazy-seed.nix} { lockDir = builtins.getEnv "NVIMX_LOCK_DIR"; sources = ${./sources.nix}; }' } - seed=$(locked_seed) - if [ -n "$seed" ]; then + # --check (#78) never runs nix, and locked_seed is a `nix eval`: it extracts against nvimx's + # own bundled lazy.nvim instead. The simplest option that stays offline; the cost is that a + # spec relying on a newer lazy.nvim feature than the bundled one could extract differently. + seed="" + if [ "$check_mode" -eq 0 ]; then + seed=$(locked_seed) + fi + if [ "$check_mode" -eq 1 ]; then + seed="${lazyNvimSeed}" + echo "nvimx-lock: lazy.nvim seed: $seed (nvimx's own; --check never runs nix)" >&2 + elif [ -n "$seed" ]; then echo "nvimx-lock: lazy.nvim seed: $seed (pinned in $out/flake.lock)" >&2 else seed="${lazyNvimSeed}"