From 797bbff7fcbe8f84f38c32eab886ce1ee0dc8cb9 Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 20:14:19 -0700 Subject: [PATCH 1/9] feat: add defaults.lazy/version and unsupported-field warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three lazy.nvim parity polish items so author-published specs and lazy.nvim configs port more cleanly: - defaults.lazy / defaults.version: setup() now honors a global lazy flag and a global version fallback, applied only when a spec doesn't set its own (version=false still opts out). Threaded through lazy.is_lazy + lazy.has_lazy_parent so dep-only children of a defaults.lazy parent are also lazy. - validate.lua: warns (does not error) on rocks/virtual/submodules — fields zpack silently ignored before. Behavior unchanged; users now see the gap. - docs/tips.md: short migration notes for the enabled/cond split (zpack separates "skip install" vs "skip load") and the unsupported-field list. --- README.md | 2 ++ docs/tips.md | 4 ++- lua/zpack/init.lua | 2 ++ lua/zpack/lazy.lua | 12 +++++-- lua/zpack/utils.lua | 9 ++++-- lua/zpack/validate.lua | 14 ++++++++ tests/conditional_test.lua | 66 ++++++++++++++++++++++++++++++++++++++ tests/validate_test.lua | 58 +++++++++++++++++++++++++++++++++ tests/version_test.lua | 52 ++++++++++++++++++++++++++++++ 9 files changed, 214 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9aabbb1..eb5f206 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ require('zpack').setup({ defaults = { confirm = true, -- set to false to skip vim.pack install prompts (default: true) cond = nil, -- global condition for all plugins, e.g. not vim.g.is_vscode (default: nil) + lazy = false, -- make every spec lazy-load by default unless it sets lazy=false (lazy.nvim parity, default: false) + version = nil, -- default version applied when a spec has no version/sem_version/branch/tag/commit (lazy.nvim parity, default: nil) }, performance = { vim_loader = true, -- enables vim.loader for faster startup (default: true) diff --git a/docs/tips.md b/docs/tips.md index 193fa22..d52a827 100644 --- a/docs/tips.md +++ b/docs/tips.md @@ -6,7 +6,9 @@ Most of your lazy.nvim plugin specs will work as-is with zpack. However, zpack f - **version pinning**: lazy.nvim's `version` field maps to zpack's `sem_version`. See [Spec Reference](spec.md) and [version pinning examples](examples.md#version-pinning-for-lazynvim-compatibility) - **dev mode**: Set `dev = true` on a spec and configure `setup({ dev = { path = '~/projects' } })` — the source is rewritten to `/`. Live file-watch / auto-reload is out of scope; use `:ZPack reload {plugin}` to manually re-source. See [Spec Reference](spec.md) for `dev`/`deactivate` - **profiling**: Use `nvim --startuptime startuptime.log`. Also refer to example [Neovim Profiler script](https://gist.github.com/zuqini/35993710f81983fbfa6baca67bdb32ed) -- **default lazy plugins**: lazy.nvim's community specs silently default top-level specs for utility libraries like `plenary.nvim` to `lazy = true`, even without lazy triggers or a lazy parent. zpack respects your specs as-written, so set `lazy = true` explicitly on such specs if you want the same default +- **default lazy plugins**: lazy.nvim's community specs silently default top-level specs for utility libraries like `plenary.nvim` to `lazy = true`, even without lazy triggers or a lazy parent. zpack respects your specs as-written, so set `lazy = true` explicitly on such specs if you want the same default — or set `defaults.lazy = true` in [`setup()`](../README.md#configurations) to apply it to every spec +- **`enabled` vs `cond`**: lazy.nvim collapses both to a single "disabled" state. zpack splits them: `enabled = false` skips install entirely (the plugin is never cloned), while `cond = false` installs the plugin but skips its load (so it still shows up under `vim.pack`'s data dir). Use `enabled` when you want lazy.nvim's "don't install at all" behavior +- **unsupported author-spec fields**: zpack does not implement `rocks` (LuaRocks deps), `virtual` (synthetic plugins), or `submodules` (git submodule control). Specs carrying these are accepted but emit a validation warning so the gap is visible; the rest of the spec loads normally ## Gotchas diff --git a/lua/zpack/init.lua b/lua/zpack/init.lua index b4961ea..a528594 100644 --- a/lua/zpack/init.lua +++ b/lua/zpack/init.lua @@ -39,6 +39,8 @@ end ---@class zpack.Config.Defaults ---@field cond? boolean|(fun(plugin: zpack.Plugin):boolean) ---@field confirm? boolean +---@field lazy? boolean Default lazy-load flag applied when a spec has no `lazy` field and no lazy triggers (lazy.nvim parity) +---@field version? string|vim.VersionRange Default version applied when a spec has no `version`/`sem_version`/`branch`/`tag`/`commit` (lazy.nvim parity) ---@class zpack.Config.Performance ---@field vim_loader? boolean diff --git a/lua/zpack/lazy.lua b/lua/zpack/lazy.lua index f6f53b7..cfa215d 100644 --- a/lua/zpack/lazy.lua +++ b/lua/zpack/lazy.lua @@ -24,6 +24,13 @@ local function is_dependency_only(src) return true end +---@return boolean +local function default_lazy() + return state.config ~= nil + and state.config.defaults ~= nil + and state.config.defaults.lazy == true +end + ---Check if any parent of a dependency is lazy (cached) ---@param dep_src string ---@return boolean @@ -39,6 +46,7 @@ local function has_lazy_parent(dep_src) return false end + local fallback_lazy = default_lazy() for parent_src in pairs(parents) do local parent_entry = state.spec_registry[parent_src] if parent_entry and parent_entry.merged_spec then @@ -52,7 +60,7 @@ local function has_lazy_parent(dep_src) local cmd = utils.try_resolve_field(parent_spec.cmd, parent_entry.plugin, parent_src, 'cmd') local ft = utils.try_resolve_field(parent_spec.ft, parent_entry.plugin, parent_src, 'ft') local keys = utils.try_resolve_field(parent_spec.keys, parent_entry.plugin, parent_src, 'keys') - if event or cmd or ft or (keys and #keys > 0) then + if event or cmd or ft or (keys and #keys > 0) or fallback_lazy then state.lazy_parent_cache[dep_src] = true return true end @@ -86,7 +94,7 @@ M.is_lazy = function(spec, plugin, src) return true end - return false + return default_lazy() end ---@param ctx zpack.ProcessContext diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 8145671..c61e37e 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -320,7 +320,7 @@ M.is_semver_like = function(str) or str:match('^%d+[%d%.]*$') ~= nil end ----Normalize plugin version using priority: version > sem_version > branch > tag > commit. +---Normalize plugin version using priority: version > sem_version > branch > tag > commit > defaults.version. ---`version = false` is a lazy.nvim escape hatch meaning "no version constraint" — ---returns nil so vim.pack tracks the default branch even when a global default would ---otherwise pin a version. @@ -329,7 +329,8 @@ end M.normalize_version = function(spec) -- `version = false` is a lazy.nvim escape hatch ("no version") that we -- treat as nil so vim.pack tracks the default branch. The early return - -- also narrows `spec.version` for the analyzer below. + -- also narrows `spec.version` for the analyzer below, and pre-empts the + -- `defaults.version` fallback so a per-spec opt-out always wins. if spec.version == false then return nil end @@ -345,6 +346,10 @@ M.normalize_version = function(spec) elseif spec.commit then return spec.commit end + local default_version = state.config and state.config.defaults and state.config.defaults.version + if default_version ~= nil then + return default_version + end return nil end diff --git a/lua/zpack/validate.lua b/lua/zpack/validate.lua index 31a30e7..1e72107 100644 --- a/lua/zpack/validate.lua +++ b/lua/zpack/validate.lua @@ -60,6 +60,8 @@ function M.validate_config(opts) if type(opts.defaults) == 'table' then check(errors, 'defaults.cond', opts.defaults.cond, { 'boolean', 'function' }) check(errors, 'defaults.confirm', opts.defaults.confirm, 'boolean') + check(errors, 'defaults.lazy', opts.defaults.lazy, 'boolean') + check(errors, 'defaults.version', opts.defaults.version, { 'string', 'table' }) end if type(opts.performance) == 'table' then check(errors, 'performance.vim_loader', opts.performance.vim_loader, 'boolean') @@ -122,6 +124,12 @@ local SPEC_FIELD_TYPES = { local SORTED_SPEC_FIELDS = vim.tbl_keys(SPEC_FIELD_TYPES) table.sort(SORTED_SPEC_FIELDS) +---lazy.nvim spec fields that zpack does not implement. Author-published +---specs occasionally carry these; silently ignoring them masks real +---behavioral gaps (e.g. a `rocks` plugin would skip its LuaRocks deps). +---Surface them so users can decide whether the gap matters. +local UNSUPPORTED_LAZY_FIELDS = { 'rocks', 'submodules', 'virtual' } + ---Validate a single plugin spec. ---@param spec any A `zpack.Spec` entry ---@return string[] errors Field-named messages; empty when valid @@ -136,6 +144,12 @@ function M.validate_spec(spec) check(errors, field, spec[field], SPEC_FIELD_TYPES[field]) end + for _, field in ipairs(UNSUPPORTED_LAZY_FIELDS) do + if spec[field] ~= nil then + errors[#errors + 1] = ('%s: unsupported lazy.nvim field, ignored by zpack'):format(field) + end + end + -- Every field above is optional, so a spec with no source at all passes -- every type check yet cannot be loaded — `import.normalize_source` would -- otherwise fail and the spec would silently never import. An `import` diff --git a/tests/conditional_test.lua b/tests/conditional_test.lua index a93345e..e12217e 100644 --- a/tests/conditional_test.lua +++ b/tests/conditional_test.lua @@ -334,3 +334,69 @@ describe("Conditional Loading", function() assert.is_truthy(utils.check_enabled(both_true), "both-true functions should merge to true") end) end) + +describe("defaults.lazy", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("makes a triggerless spec lazy when defaults.lazy=true", function() + local state = require('zpack.state') + + require('zpack').setup({ + spec = { { 'test/plugin' } }, + defaults = { confirm = false, lazy = true }, + }) + helpers.flush_pending() + + local entry = state.spec_registry['https://github.com/test/plugin'] + assert.is_not_nil(entry, "Plugin should be registered") + assert.is_true(entry.is_lazy_resolved, "Plugin should be lazy via defaults.lazy") + end) + + it("spec.lazy=false overrides defaults.lazy=true", function() + local state = require('zpack.state') + + require('zpack').setup({ + spec = { { 'test/plugin', lazy = false } }, + defaults = { confirm = false, lazy = true }, + }) + helpers.flush_pending() + + local entry = state.spec_registry['https://github.com/test/plugin'] + assert.is_not_nil(entry) + assert.is_false(entry.is_lazy_resolved, "Explicit lazy=false should win over defaults.lazy") + end) + + it("defaults.lazy=false (the default) keeps a triggerless spec eager", function() + local state = require('zpack.state') + + require('zpack').setup({ + spec = { { 'test/plugin' } }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local entry = state.spec_registry['https://github.com/test/plugin'] + assert.is_not_nil(entry) + assert.is_false(entry.is_lazy_resolved, "Without defaults.lazy, a triggerless spec stays eager") + end) + + it("a dep of a defaults.lazy=true parent is also resolved as lazy", function() + local state = require('zpack.state') + + require('zpack').setup({ + spec = { + { 'test/parent', dependencies = { 'test/child' } }, + }, + defaults = { confirm = false, lazy = true }, + }) + helpers.flush_pending() + + local parent = state.spec_registry['https://github.com/test/parent'] + local child = state.spec_registry['https://github.com/test/child'] + assert.is_not_nil(parent) + assert.is_not_nil(child) + assert.is_true(parent.is_lazy_resolved, "Parent should be lazy via defaults.lazy") + assert.is_true(child.is_lazy_resolved, "Dep-only child should inherit laziness from parent") + end) +end) diff --git a/tests/validate_test.lua b/tests/validate_test.lua index d962fa6..eddae0e 100644 --- a/tests/validate_test.lua +++ b/tests/validate_test.lua @@ -77,6 +77,37 @@ describe("Config Validation", function() }) assert.are.equal(0, #errors) end) + + it("validate_config accepts defaults.lazy and defaults.version", function() + local validate = require('zpack.validate') + local errors = validate.validate_config({ + defaults = { lazy = true, version = 'main' }, + }) + assert.are.equal(0, #errors) + + errors = validate.validate_config({ + defaults = { version = vim.version.range('^1') }, + }) + assert.are.equal(0, #errors) + end) + + it("validate_config flags a non-boolean defaults.lazy", function() + local validate = require('zpack.validate') + local errors = validate.validate_config({ defaults = { lazy = 'yes' } }) + assert.are.equal(1, #errors) + assert.is_truthy(errors[1]:find('defaults.lazy', 1, true) ~= nil, + "error should name defaults.lazy") + assert.is_truthy(errors[1]:find('boolean', 1, true) ~= nil, + "error should state expected boolean") + end) + + it("validate_config flags a wrong-typed defaults.version", function() + local validate = require('zpack.validate') + local errors = validate.validate_config({ defaults = { version = 123 } }) + assert.are.equal(1, #errors) + assert.is_truthy(errors[1]:find('defaults.version', 1, true) ~= nil, + "error should name defaults.version") + end) end) describe("Spec Validation", function() @@ -139,6 +170,33 @@ describe("Spec Validation", function() local errors = validate.validate_spec({ import = 'plugins' }) assert.are.equal(0, #errors) end) + + it("validate_spec flags unsupported lazy.nvim fields (rocks/virtual/submodules)", function() + local validate = require('zpack.validate') + + for _, field in ipairs({ 'rocks', 'virtual', 'submodules' }) do + local spec = { 'user/plugin' } + spec[field] = field == 'rocks' and { 'pkg' } or true + local errors = validate.validate_spec(spec) + assert.are.equal(1, #errors, ('one warning expected for unsupported field %s'):format(field)) + assert.is_truthy(errors[1]:find(field, 1, true) ~= nil, + ('warning should name the unsupported field %s'):format(field)) + assert.is_truthy(errors[1]:find('unsupported', 1, true) ~= nil, + "warning should describe the field as unsupported") + end + end) + + it("validate_spec reports every unsupported field on the same spec", function() + local validate = require('zpack.validate') + local errors = validate.validate_spec({ + 'user/plugin', + rocks = { 'pkg' }, + virtual = true, + submodules = false, + }) + assert.are.equal(3, #errors, + "rocks, virtual, and submodules should each produce a warning") + end) end) describe("Validation wired into setup()", function() diff --git a/tests/version_test.lua b/tests/version_test.lua index bf13610..f6b58f5 100644 --- a/tests/version_test.lua +++ b/tests/version_test.lua @@ -292,3 +292,55 @@ describe("Version Normalization", function() vim.pack.add = mock_vim_pack_add end) end) + +describe("defaults.version", function() + before_each(helpers.setup_test_env) + after_each(helpers.cleanup_test_env) + + it("applies defaults.version when a spec has no version fields", function() + require('zpack').setup({ + spec = { { 'test/plugin' } }, + defaults = { confirm = false, version = 'main' }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.are.equal('main', call[1].version) + end) + + it("per-spec version wins over defaults.version", function() + require('zpack').setup({ + spec = { { 'test/plugin', version = 'develop' } }, + defaults = { confirm = false, version = 'main' }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.are.equal('develop', call[1].version) + end) + + it("version=false escape hatch wins over defaults.version", function() + require('zpack').setup({ + spec = { { 'test/plugin', version = false } }, + defaults = { confirm = false, version = 'main' }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.is_nil(call[1].version, "version=false must opt out of defaults.version") + end) + + it("defaults.version accepts a vim.VersionRange table", function() + local range = vim.version.range('^1') + + require('zpack').setup({ + spec = { { 'test/plugin' } }, + defaults = { confirm = false, version = range }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.are.equal('table', type(call[1].version)) + assert.is_not_nil(call[1].version.from, "VersionRange should have 'from' field") + end) +end) From 9da9677ecf3f3458510000617854e4a0d6c8921d Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 22:01:06 -0700 Subject: [PATCH 2/9] fix: treat defaults.version=false as no default; doc + regression guards A user who ignored the validator's "expected string|table" warning would leak boolean false through normalize_version into vim.pack.add. Treat it as nil instead, mirroring the per-spec version=false escape hatch. Also document defaults.lazy/defaults.version in doc/zpack.txt (README and the LuaCATS class already had them) and add regression guards for the version priority chain (branch/sem_version vs defaults.version) and the defaults.lazy + explicit-trigger interaction. Trim verbose doc-comments on the same change for the no-comments-unless- non-obvious rule. --- doc/zpack.txt | 9 +++++++++ lua/zpack/utils.lua | 8 +++----- lua/zpack/validate.lua | 6 ++---- tests/conditional_test.lua | 14 ++++++++++++++ tests/version_test.lua | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/doc/zpack.txt b/doc/zpack.txt index a189356..06b0968 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -170,6 +170,8 @@ command name is configurable via |zpack-setup-cmd_name| — a short name like defaults = { confirm = true, -- set to false to skip vim.pack install prompts (default: true) cond = nil, -- global condition for all plugins, e.g. not vim.g.is_vscode (default: nil) + lazy = false, -- make every spec lazy-load by default unless it sets lazy=false (lazy.nvim parity, default: false) + version = nil, -- default version applied when a spec has no version/sem_version/branch/tag/commit (lazy.nvim parity, default: nil) }, performance = { vim_loader = true, -- enables vim.loader for faster startup (default: true) @@ -250,6 +252,13 @@ defaults (table, optional) settings always take precedence over defaults. - `confirm`: Skip vim.pack install prompts. Default: `true` - `cond`: Global condition for all plugins. + - `lazy`: When `true`, every spec is treated as lazy + unless it sets `lazy = false`. lazy.nvim parity. + Default: `false`. + - `version`: Default version applied when a spec has no + `version`/`sem_version`/`branch`/`tag`/`commit`. A + per-spec `version = false` opts out. lazy.nvim parity. + Default: `nil`. performance (table, optional) Performance-related settings. diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index c61e37e..4467aee 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -327,10 +327,8 @@ end ---@param spec zpack.Spec ---@return string|vim.VersionRange|nil version M.normalize_version = function(spec) - -- `version = false` is a lazy.nvim escape hatch ("no version") that we - -- treat as nil so vim.pack tracks the default branch. The early return - -- also narrows `spec.version` for the analyzer below, and pre-empts the - -- `defaults.version` fallback so a per-spec opt-out always wins. + -- `version = false` is lazy.nvim's "no version constraint" escape hatch; + -- pre-empts `defaults.version` so a per-spec opt-out always wins. if spec.version == false then return nil end @@ -347,7 +345,7 @@ M.normalize_version = function(spec) return spec.commit end local default_version = state.config and state.config.defaults and state.config.defaults.version - if default_version ~= nil then + if default_version ~= nil and default_version ~= false then return default_version end return nil diff --git a/lua/zpack/validate.lua b/lua/zpack/validate.lua index 1e72107..5b009b0 100644 --- a/lua/zpack/validate.lua +++ b/lua/zpack/validate.lua @@ -124,10 +124,8 @@ local SPEC_FIELD_TYPES = { local SORTED_SPEC_FIELDS = vim.tbl_keys(SPEC_FIELD_TYPES) table.sort(SORTED_SPEC_FIELDS) ----lazy.nvim spec fields that zpack does not implement. Author-published ----specs occasionally carry these; silently ignoring them masks real ----behavioral gaps (e.g. a `rocks` plugin would skip its LuaRocks deps). ----Surface them so users can decide whether the gap matters. +---lazy.nvim spec fields zpack does not implement; warned so the gap is visible +---(e.g. `rocks` would silently skip the plugin's LuaRocks deps). local UNSUPPORTED_LAZY_FIELDS = { 'rocks', 'submodules', 'virtual' } ---Validate a single plugin spec. diff --git a/tests/conditional_test.lua b/tests/conditional_test.lua index e12217e..3316ad1 100644 --- a/tests/conditional_test.lua +++ b/tests/conditional_test.lua @@ -399,4 +399,18 @@ describe("defaults.lazy", function() assert.is_true(parent.is_lazy_resolved, "Parent should be lazy via defaults.lazy") assert.is_true(child.is_lazy_resolved, "Dep-only child should inherit laziness from parent") end) + + it("a spec with an explicit event trigger stays lazy under defaults.lazy=true", function() + local state = require('zpack.state') + + require('zpack').setup({ + spec = { { 'test/plugin', event = 'BufRead' } }, + defaults = { confirm = false, lazy = true }, + }) + helpers.flush_pending() + + local entry = state.spec_registry['https://github.com/test/plugin'] + assert.is_not_nil(entry) + assert.is_true(entry.is_lazy_resolved) + end) end) diff --git a/tests/version_test.lua b/tests/version_test.lua index f6b58f5..1994a29 100644 --- a/tests/version_test.lua +++ b/tests/version_test.lua @@ -343,4 +343,38 @@ describe("defaults.version", function() assert.are.equal('table', type(call[1].version)) assert.is_not_nil(call[1].version.from, "VersionRange should have 'from' field") end) + + it("per-spec branch wins over defaults.version", function() + require('zpack').setup({ + spec = { { 'test/plugin', branch = 'develop' } }, + defaults = { confirm = false, version = 'main' }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.are.equal('develop', call[1].version) + end) + + it("per-spec sem_version wins over defaults.version", function() + require('zpack').setup({ + spec = { { 'test/plugin', sem_version = '^2' } }, + defaults = { confirm = false, version = 'main' }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.are.equal('table', type(call[1].version)) + assert.is_not_nil(call[1].version.from) + end) + + it("defaults.version=false is treated as no default (graceful fallback)", function() + require('zpack').setup({ + spec = { { 'test/plugin' } }, + defaults = { confirm = false, version = false }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.is_nil(call[1].version, "defaults.version=false must not leak boolean to vim.pack.add") + end) end) From 10251bc9f656f98b17a41f1667d0032c2f84b9f8 Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 23:01:59 -0700 Subject: [PATCH 3/9] fix: accept defaults.version=false in validator The runtime treats defaults.version=false as the no-default opt-out (utils.normalize_version, pinned by version_test.lua "defaults.version=false is treated as no default") but validate_config typed the field as {string, table} only, so users writing the documented opt-out got a spurious "expected string|table, got boolean" notify at every setup(). Widens the validator to {string, table, boolean}, mirroring SPEC_FIELD_TYPES.version which already accepts the same shape for per-spec values. --- lua/zpack/validate.lua | 3 ++- tests/validate_test.lua | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lua/zpack/validate.lua b/lua/zpack/validate.lua index 5b009b0..9051741 100644 --- a/lua/zpack/validate.lua +++ b/lua/zpack/validate.lua @@ -61,7 +61,8 @@ function M.validate_config(opts) check(errors, 'defaults.cond', opts.defaults.cond, { 'boolean', 'function' }) check(errors, 'defaults.confirm', opts.defaults.confirm, 'boolean') check(errors, 'defaults.lazy', opts.defaults.lazy, 'boolean') - check(errors, 'defaults.version', opts.defaults.version, { 'string', 'table' }) + -- `boolean` accepts `version = false`, the no-default opt-out handled in utils.normalize_version. + check(errors, 'defaults.version', opts.defaults.version, { 'string', 'table', 'boolean' }) end if type(opts.performance) == 'table' then check(errors, 'performance.vim_loader', opts.performance.vim_loader, 'boolean') diff --git a/tests/validate_test.lua b/tests/validate_test.lua index eddae0e..ea3bae4 100644 --- a/tests/validate_test.lua +++ b/tests/validate_test.lua @@ -108,6 +108,13 @@ describe("Config Validation", function() assert.is_truthy(errors[1]:find('defaults.version', 1, true) ~= nil, "error should name defaults.version") end) + + it("validate_config accepts defaults.version=false (the no-default opt-out)", function() + local validate = require('zpack.validate') + local errors = validate.validate_config({ defaults = { version = false } }) + assert.are.equal(0, #errors, + "defaults.version=false is the documented opt-out and must not error") + end) end) describe("Spec Validation", function() From 6f566c64e62d93251b60e956e35df4d4160635a9 Mon Sep 17 00:00:00 2001 From: zuqini Date: Mon, 25 May 2026 23:02:07 -0700 Subject: [PATCH 4/9] refactor: lift trigger-OR-floor into shared lazy-resolution helper is_lazy and has_lazy_parent both evaluated event/cmd/ft/keys triggers and the defaults.lazy floor, with the floor woven in slightly differently at each site (terminal fallback vs. OR'd into the trigger check). The trigger-OR-floor decision now lives in one is_lazy_by_triggers_or_floor local; both callers route through it, so a future refinement to lazy resolution lands in one place. Behavior-preserving (497/0 busted, validated under defaults.lazy=true and false on triggerless specs, dep-of-lazy-parent, explicit spec.lazy opt-out, and triggered specs). --- lua/zpack/lazy.lua | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/lua/zpack/lazy.lua b/lua/zpack/lazy.lua index cfa215d..da903f9 100644 --- a/lua/zpack/lazy.lua +++ b/lua/zpack/lazy.lua @@ -31,6 +31,22 @@ local function default_lazy() and state.config.defaults.lazy == true end +---@param spec zpack.Spec +---@param plugin zpack.Plugin? +---@param src? string +---@return boolean +local function is_lazy_by_triggers_or_floor(spec, plugin, src) + local event = utils.try_resolve_field(spec.event, plugin, src, 'event') + local cmd = utils.try_resolve_field(spec.cmd, plugin, src, 'cmd') + local ft = utils.try_resolve_field(spec.ft, plugin, src, 'ft') + local keys = utils.try_resolve_field(spec.keys, plugin, src, 'keys') + + if event or cmd or ft or (keys and #keys > 0) then + return true + end + return default_lazy() +end + ---Check if any parent of a dependency is lazy (cached) ---@param dep_src string ---@return boolean @@ -46,25 +62,17 @@ local function has_lazy_parent(dep_src) return false end - local fallback_lazy = default_lazy() for parent_src in pairs(parents) do local parent_entry = state.spec_registry[parent_src] if parent_entry and parent_entry.merged_spec then local parent_spec = parent_entry.merged_spec --[[@as zpack.Spec]] - if parent_spec.lazy == true then + if parent_spec.lazy == true + or (parent_spec.lazy == nil + and is_lazy_by_triggers_or_floor(parent_spec, parent_entry.plugin, parent_src)) + then state.lazy_parent_cache[dep_src] = true return true end - if parent_spec.lazy == nil then - local event = utils.try_resolve_field(parent_spec.event, parent_entry.plugin, parent_src, 'event') - local cmd = utils.try_resolve_field(parent_spec.cmd, parent_entry.plugin, parent_src, 'cmd') - local ft = utils.try_resolve_field(parent_spec.ft, parent_entry.plugin, parent_src, 'ft') - local keys = utils.try_resolve_field(parent_spec.keys, parent_entry.plugin, parent_src, 'keys') - if event or cmd or ft or (keys and #keys > 0) or fallback_lazy then - state.lazy_parent_cache[dep_src] = true - return true - end - end end end @@ -81,12 +89,7 @@ M.is_lazy = function(spec, plugin, src) return spec.lazy end - local event = utils.try_resolve_field(spec.event, plugin, src, 'event') - local cmd = utils.try_resolve_field(spec.cmd, plugin, src, 'cmd') - local ft = utils.try_resolve_field(spec.ft, plugin, src, 'ft') - local keys = utils.try_resolve_field(spec.keys, plugin, src, 'keys') - - if event or cmd or ft or (keys and #keys > 0) then + if is_lazy_by_triggers_or_floor(spec, plugin, src) then return true end @@ -94,7 +97,7 @@ M.is_lazy = function(spec, plugin, src) return true end - return default_lazy() + return false end ---@param ctx zpack.ProcessContext From 9406c720f254cf22784f986f2f425424fb1ee187 Mon Sep 17 00:00:00 2001 From: zuqini Date: Tue, 26 May 2026 00:29:56 -0700 Subject: [PATCH 5/9] refactor: short-circuit lazy floor; tighten defaults.version annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_lazy_by_triggers_or_floor now checks defaults.lazy before resolving trigger fields. When the floor implies lazy, the four try_resolve_field calls are skipped, eliminating up to four duplicate "Failed to resolve" notifies per setup() for throwing function-form resolvers in the defaults.lazy=true case — the same redundant-notify concern the existing "try_resolve_field re-invokes the same function-form spec field across the setup pipeline (no caching)" decision flagged. Same answer either order, since the helper is a disjunction. defaults.version's LuaCATS annotation gains |false to match the validator and utils.normalize_version (types.lua already does this for the per-spec field); silences an LSP false-positive on the documented `defaults = { version = false }` opt-out. Also collapse default_lazy() to the bare-`and` idiom used elsewhere in the PR (utils.normalize_version line 347), and drop one inline comment in normalize_version that the doc-comment above it already covers. 497/0 busted, 0/0 luacheck. --- lua/zpack/init.lua | 4 ++-- lua/zpack/lazy.lua | 9 +++++---- lua/zpack/utils.lua | 2 -- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lua/zpack/init.lua b/lua/zpack/init.lua index a528594..2891bf6 100644 --- a/lua/zpack/init.lua +++ b/lua/zpack/init.lua @@ -39,8 +39,8 @@ end ---@class zpack.Config.Defaults ---@field cond? boolean|(fun(plugin: zpack.Plugin):boolean) ---@field confirm? boolean ----@field lazy? boolean Default lazy-load flag applied when a spec has no `lazy` field and no lazy triggers (lazy.nvim parity) ----@field version? string|vim.VersionRange Default version applied when a spec has no `version`/`sem_version`/`branch`/`tag`/`commit` (lazy.nvim parity) +---@field lazy? boolean Treat every spec as lazy unless it sets `lazy = false` (lazy.nvim parity) +---@field version? string|vim.VersionRange|false Default version when none set; `false` means no default (lazy.nvim parity) ---@class zpack.Config.Performance ---@field vim_loader? boolean diff --git a/lua/zpack/lazy.lua b/lua/zpack/lazy.lua index da903f9..ea4275b 100644 --- a/lua/zpack/lazy.lua +++ b/lua/zpack/lazy.lua @@ -26,9 +26,7 @@ end ---@return boolean local function default_lazy() - return state.config ~= nil - and state.config.defaults ~= nil - and state.config.defaults.lazy == true + return (state.config and state.config.defaults and state.config.defaults.lazy) == true end ---@param spec zpack.Spec @@ -36,6 +34,9 @@ end ---@param src? string ---@return boolean local function is_lazy_by_triggers_or_floor(spec, plugin, src) + if default_lazy() then + return true + end local event = utils.try_resolve_field(spec.event, plugin, src, 'event') local cmd = utils.try_resolve_field(spec.cmd, plugin, src, 'cmd') local ft = utils.try_resolve_field(spec.ft, plugin, src, 'ft') @@ -44,7 +45,7 @@ local function is_lazy_by_triggers_or_floor(spec, plugin, src) if event or cmd or ft or (keys and #keys > 0) then return true end - return default_lazy() + return false end ---Check if any parent of a dependency is lazy (cached) diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 4467aee..8dc9845 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -327,8 +327,6 @@ end ---@param spec zpack.Spec ---@return string|vim.VersionRange|nil version M.normalize_version = function(spec) - -- `version = false` is lazy.nvim's "no version constraint" escape hatch; - -- pre-empts `defaults.version` so a per-spec opt-out always wins. if spec.version == false then return nil end From ad7641dc79185a4b961251b83e631fd8b3c9f0f3 Mon Sep 17 00:00:00 2001 From: zuqini Date: Tue, 26 May 2026 08:05:05 -0700 Subject: [PATCH 6/9] revert: drop UNSUPPORTED_LAZY_FIELDS warning (scope creep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 797bbff added a per-spec warning loop in validate_spec that flagged rocks/submodules/virtual as "unsupported lazy.nvim field, ignored by zpack". Revert: the project's framing is "lazy.nvim spec drop-in compatibility" for the spec shape, not "policer of authoring style". Warning at setup() time on every unknown field shifts zpack from "thin layer over vim.pack" to runtime gatekeeper, which is scope creep. The README and docs/tips.md still document the gaps once, which is the right surface for migrating users. A truly load-bearing field (e.g. rocks enabling LuaRocks deps) surfaces as a runtime require-failure when the plugin's Lua side can't find the dep — clearer than a validator warning naming a field whose absence the user may not care about. Also removes the two validate_test.lua tests that pinned the warning, and the corresponding bullet from docs/tips.md. Decision pinned in .claude/review-decisions.md as "Unsupported lazy.nvim spec fields are silently accepted". --- docs/tips.md | 1 - lua/zpack/validate.lua | 10 ---------- tests/validate_test.lua | 27 --------------------------- 3 files changed, 38 deletions(-) diff --git a/docs/tips.md b/docs/tips.md index d52a827..52f8527 100644 --- a/docs/tips.md +++ b/docs/tips.md @@ -8,7 +8,6 @@ Most of your lazy.nvim plugin specs will work as-is with zpack. However, zpack f - **profiling**: Use `nvim --startuptime startuptime.log`. Also refer to example [Neovim Profiler script](https://gist.github.com/zuqini/35993710f81983fbfa6baca67bdb32ed) - **default lazy plugins**: lazy.nvim's community specs silently default top-level specs for utility libraries like `plenary.nvim` to `lazy = true`, even without lazy triggers or a lazy parent. zpack respects your specs as-written, so set `lazy = true` explicitly on such specs if you want the same default — or set `defaults.lazy = true` in [`setup()`](../README.md#configurations) to apply it to every spec - **`enabled` vs `cond`**: lazy.nvim collapses both to a single "disabled" state. zpack splits them: `enabled = false` skips install entirely (the plugin is never cloned), while `cond = false` installs the plugin but skips its load (so it still shows up under `vim.pack`'s data dir). Use `enabled` when you want lazy.nvim's "don't install at all" behavior -- **unsupported author-spec fields**: zpack does not implement `rocks` (LuaRocks deps), `virtual` (synthetic plugins), or `submodules` (git submodule control). Specs carrying these are accepted but emit a validation warning so the gap is visible; the rest of the spec loads normally ## Gotchas diff --git a/lua/zpack/validate.lua b/lua/zpack/validate.lua index 9051741..28bedc9 100644 --- a/lua/zpack/validate.lua +++ b/lua/zpack/validate.lua @@ -125,10 +125,6 @@ local SPEC_FIELD_TYPES = { local SORTED_SPEC_FIELDS = vim.tbl_keys(SPEC_FIELD_TYPES) table.sort(SORTED_SPEC_FIELDS) ----lazy.nvim spec fields zpack does not implement; warned so the gap is visible ----(e.g. `rocks` would silently skip the plugin's LuaRocks deps). -local UNSUPPORTED_LAZY_FIELDS = { 'rocks', 'submodules', 'virtual' } - ---Validate a single plugin spec. ---@param spec any A `zpack.Spec` entry ---@return string[] errors Field-named messages; empty when valid @@ -143,12 +139,6 @@ function M.validate_spec(spec) check(errors, field, spec[field], SPEC_FIELD_TYPES[field]) end - for _, field in ipairs(UNSUPPORTED_LAZY_FIELDS) do - if spec[field] ~= nil then - errors[#errors + 1] = ('%s: unsupported lazy.nvim field, ignored by zpack'):format(field) - end - end - -- Every field above is optional, so a spec with no source at all passes -- every type check yet cannot be loaded — `import.normalize_source` would -- otherwise fail and the spec would silently never import. An `import` diff --git a/tests/validate_test.lua b/tests/validate_test.lua index ea3bae4..0cee212 100644 --- a/tests/validate_test.lua +++ b/tests/validate_test.lua @@ -177,33 +177,6 @@ describe("Spec Validation", function() local errors = validate.validate_spec({ import = 'plugins' }) assert.are.equal(0, #errors) end) - - it("validate_spec flags unsupported lazy.nvim fields (rocks/virtual/submodules)", function() - local validate = require('zpack.validate') - - for _, field in ipairs({ 'rocks', 'virtual', 'submodules' }) do - local spec = { 'user/plugin' } - spec[field] = field == 'rocks' and { 'pkg' } or true - local errors = validate.validate_spec(spec) - assert.are.equal(1, #errors, ('one warning expected for unsupported field %s'):format(field)) - assert.is_truthy(errors[1]:find(field, 1, true) ~= nil, - ('warning should name the unsupported field %s'):format(field)) - assert.is_truthy(errors[1]:find('unsupported', 1, true) ~= nil, - "warning should describe the field as unsupported") - end - end) - - it("validate_spec reports every unsupported field on the same spec", function() - local validate = require('zpack.validate') - local errors = validate.validate_spec({ - 'user/plugin', - rocks = { 'pkg' }, - virtual = true, - submodules = false, - }) - assert.are.equal(3, #errors, - "rocks, virtual, and submodules should each produce a warning") - end) end) describe("Validation wired into setup()", function() From a77c890f64012a2dfad34db860fcf72aebf51a16 Mon Sep 17 00:00:00 2001 From: zuqini Date: Tue, 26 May 2026 08:05:23 -0700 Subject: [PATCH 7/9] fix: treat defaults.version=true as silent no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator accepts defaults.version as {string, table, boolean} so that defaults.version=false (the documented no-default opt-out) passes. But normalize_version's fallback guard was `default_version ~= nil and default_version ~= false`, which let defaults.version=true through and propagated the boolean into vim.pack.add's spec table — surfacing as a less-obvious downstream error. Tighten normalize_version to accept only string|table values for the defaults.version fallback. Both true and false now silently no-op, matching how spec-level `version = true` already falls through the if-elseif chain as a silent no-op. The validator stays type-lenient because vim.validate's flat type list can't express "string|table OR exactly false"; the runtime guard does the precise filtering. Adds two regression tests in version_test.lua: defaults.version=true must not leak to vim.pack.add, and defaults.version=false must not clobber an explicit per-spec version. Decision pinned in .claude/review-decisions.md as "defaults.version: both true and false are silent no-ops". --- lua/zpack/utils.lua | 2 +- tests/version_test.lua | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lua/zpack/utils.lua b/lua/zpack/utils.lua index 8dc9845..ad4687c 100644 --- a/lua/zpack/utils.lua +++ b/lua/zpack/utils.lua @@ -343,7 +343,7 @@ M.normalize_version = function(spec) return spec.commit end local default_version = state.config and state.config.defaults and state.config.defaults.version - if default_version ~= nil and default_version ~= false then + if type(default_version) == 'string' or type(default_version) == 'table' then return default_version end return nil diff --git a/tests/version_test.lua b/tests/version_test.lua index 1994a29..38cc2de 100644 --- a/tests/version_test.lua +++ b/tests/version_test.lua @@ -377,4 +377,26 @@ describe("defaults.version", function() assert.is_not_nil(call) assert.is_nil(call[1].version, "defaults.version=false must not leak boolean to vim.pack.add") end) + + it("defaults.version=false does not clobber an explicit per-spec version", function() + require('zpack').setup({ + spec = { { 'test/plugin', version = 'main' } }, + defaults = { confirm = false, version = false }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.are.equal('main', call[1].version) + end) + + it("defaults.version=true is treated as no default (silent no-op, like spec.version=true)", function() + require('zpack').setup({ + spec = { { 'test/plugin' } }, + defaults = { confirm = false, version = true }, + }) + + local call = _G.test_state.vim_pack_calls[1] + assert.is_not_nil(call) + assert.is_nil(call[1].version, "defaults.version=true must not leak boolean to vim.pack.add") + end) end) From 0764f194f8601f34fd82ff9c14ea28dd6b20f9ea Mon Sep 17 00:00:00 2001 From: zuqini Date: Tue, 26 May 2026 08:05:34 -0700 Subject: [PATCH 8/9] refactor: hoist default_lazy() to is_lazy; rename helper to has_lazy_triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_lazy_by_triggers_or_floor conflated two unrelated concerns: the spec-independent defaults.lazy short-circuit and the spec-dependent trigger resolution. Once default_lazy() is hoisted into M.is_lazy between the explicit-lazy guard and the trigger check, has_lazy_parent is only reached when default_lazy() is false — so the inner default_lazy() check at that site becomes dead. has_lazy_parent now answers a single, narrower question: "does any parent have an explicit lazy=true or a lazy trigger?" Renames the helper to has_lazy_triggers — accurate and matches the is_dependency_only naming pattern in the same file. Behavior-preserving (501/0 busted). The lazy_parent_cache invariant is unchanged; every yes-path still caches. Also adds two regression tests in conditional_test.lua: - pin has_lazy_parent inheritance via a triggered parent (the existing defaults.lazy=true test short-circuited via default_lazy() before reaching has_lazy_parent, so the inheritance path was effectively un-pinned); - pin that enabled=false wins over defaults.lazy=true (the prune layer runs upstream of is_lazy). --- lua/zpack/lazy.lua | 15 ++++++++------- tests/conditional_test.lua | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/lua/zpack/lazy.lua b/lua/zpack/lazy.lua index ea4275b..3c9f19b 100644 --- a/lua/zpack/lazy.lua +++ b/lua/zpack/lazy.lua @@ -33,10 +33,7 @@ end ---@param plugin zpack.Plugin? ---@param src? string ---@return boolean -local function is_lazy_by_triggers_or_floor(spec, plugin, src) - if default_lazy() then - return true - end +local function has_lazy_triggers(spec, plugin, src) local event = utils.try_resolve_field(spec.event, plugin, src, 'event') local cmd = utils.try_resolve_field(spec.cmd, plugin, src, 'cmd') local ft = utils.try_resolve_field(spec.ft, plugin, src, 'ft') @@ -48,7 +45,7 @@ local function is_lazy_by_triggers_or_floor(spec, plugin, src) return false end ----Check if any parent of a dependency is lazy (cached) +---Check if any parent of a dependency is lazy (cached). ---@param dep_src string ---@return boolean local function has_lazy_parent(dep_src) @@ -69,7 +66,7 @@ local function has_lazy_parent(dep_src) local parent_spec = parent_entry.merged_spec --[[@as zpack.Spec]] if parent_spec.lazy == true or (parent_spec.lazy == nil - and is_lazy_by_triggers_or_floor(parent_spec, parent_entry.plugin, parent_src)) + and has_lazy_triggers(parent_spec, parent_entry.plugin, parent_src)) then state.lazy_parent_cache[dep_src] = true return true @@ -90,7 +87,11 @@ M.is_lazy = function(spec, plugin, src) return spec.lazy end - if is_lazy_by_triggers_or_floor(spec, plugin, src) then + if default_lazy() then + return true + end + + if has_lazy_triggers(spec, plugin, src) then return true end diff --git a/tests/conditional_test.lua b/tests/conditional_test.lua index 3316ad1..0f66944 100644 --- a/tests/conditional_test.lua +++ b/tests/conditional_test.lua @@ -397,7 +397,26 @@ describe("defaults.lazy", function() assert.is_not_nil(parent) assert.is_not_nil(child) assert.is_true(parent.is_lazy_resolved, "Parent should be lazy via defaults.lazy") - assert.is_true(child.is_lazy_resolved, "Dep-only child should inherit laziness from parent") + assert.is_true(child.is_lazy_resolved, "Dep-only child should be lazy under defaults.lazy") + end) + + it("a dep-only child inherits laziness from a triggered parent (no defaults.lazy)", function() + local state = require('zpack.state') + + require('zpack').setup({ + spec = { + { 'test/parent', event = 'BufRead', dependencies = { 'test/child' } }, + }, + defaults = { confirm = false }, + }) + helpers.flush_pending() + + local parent = state.spec_registry['https://github.com/test/parent'] + local child = state.spec_registry['https://github.com/test/child'] + assert.is_not_nil(parent) + assert.is_not_nil(child) + assert.is_true(parent.is_lazy_resolved, "Parent should be lazy via its event trigger") + assert.is_true(child.is_lazy_resolved, "Dep-only child should inherit laziness from a triggered parent") end) it("a spec with an explicit event trigger stays lazy under defaults.lazy=true", function() @@ -413,4 +432,17 @@ describe("defaults.lazy", function() assert.is_not_nil(entry) assert.is_true(entry.is_lazy_resolved) end) + + it("enabled=false wins over defaults.lazy=true (plugin is never registered)", function() + local state = require('zpack.state') + + require('zpack').setup({ + spec = { { 'test/plugin', enabled = false } }, + defaults = { confirm = false, lazy = true }, + }) + helpers.flush_pending() + + assert.is_nil(state.spec_registry['https://github.com/test/plugin'], + "enabled=false should prune the plugin even under defaults.lazy=true") + end) end) From 2eb40d7d86dd292cd3cca85986fce6200d842a5c Mon Sep 17 00:00:00 2001 From: zuqini Date: Tue, 26 May 2026 08:05:46 -0700 Subject: [PATCH 9/9] docs: mirror enabled-vs-cond into helpdoc; surface defaults.lazy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/tips.md gained an "enabled vs cond" bullet and a mention of defaults.lazy=true in the default-lazy bullet during the lazy-parity work, but the parallel §11 MIGRATING FROM LAZY.NVIM section in doc/zpack.txt was not updated. Mirrors both: the default-lazy entry now points at defaults.lazy=true as an alternative to per-spec lazy=true, and a new zpack-migration-enabled-cond entry explains the enabled/cond split that lazy.nvim collapses. --- doc/zpack.txt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/doc/zpack.txt b/doc/zpack.txt index 06b0968..3296e65 100644 --- a/doc/zpack.txt +++ b/doc/zpack.txt @@ -1091,7 +1091,17 @@ default lazy plugins lazy.nvim's community specs silently default lazy triggers or a lazy parent. zpack respects your specs as-written, so set `lazy = true` explicitly on such specs if you want the same - default. + default, or set `defaults.lazy = true` in + `setup()` to apply it to every spec. + + *zpack-migration-enabled-cond* +enabled vs cond lazy.nvim collapses both to a single "disabled" + state. zpack splits them: `enabled = false` skips + install entirely (the plugin is never cloned), + while `cond = false` installs the plugin but + skips its load (so it still shows up under + `vim.pack`'s data dir). Use `enabled` when you + want lazy.nvim's "don't install at all" behavior. ------------------------------------------------------------------------------ 11.2 GOTCHAS *zpack-gotchas*