Skip to content

chore: promote next to main - #494

Merged
ss-o merged 13 commits into
mainfrom
next
Sep 3, 2026
Merged

chore: promote next to main#494
ss-o merged 13 commits into
mainfrom
next

Conversation

@ss-o

@ss-o ss-o commented Sep 3, 2026

Copy link
Copy Markdown
Member

Promotion readiness record

Candidate identity

  • Prior main SHA: 38c63c274ce6bc4d05f5ab595c7731dd4eab9660

  • Candidate next SHA: d84132175d87edb8fc42de81bbcea7b1de9a6a07

  • Candidate tree SHA: ac6f85dd41768003db5f2f3fa94fab2cd03b24d3

  • Complete compare:
    main...next

  • git diff --name-only d841321...38c63c2 produces no output.

  • git rev-parse 'd841321^{tree}' equals ac6f85dd41768003db5f2f3fa94fab2cd03b24d3.

git log --oneline d841321..38c63c2 is also empty, so main holds no stable-only commit the candidate lacks.

Required validation

Validation Stable job name Result
Zsh syntax and compile Zsh syntax and compile Pass
ZD ZUnit integration ZD integration Pass
Trunk Trunk Pass
CodeQL CodeQL Pass
Exact-candidate clean install and startup Clean install and startup Pass
Aggregate Promotion gate Pass
  • The candidate SHA has not changed since every required check completed. Re-fetched at merge time: base 38c63c2, head d841321.
  • The Guard main branch source and Promotion gate required contexts pass. 155 checks green, none failing or skipped.
  • The complete file and commit compare contains only reviewed work: 13 commits, each from a merged pull request.

Readiness review

Why now

Released main carries #471: compinit run from inside a plug-in body leaves foreign completion functions unresolvable, which breaks Aloxaf/fzf-tab and takes completions with it. #472 fixed it on next before v2.0.0 shipped and has never been promoted, so every consumer of the stable ref has been exposed since.

This is now independently demonstrated rather than argued. z-shell/zd#116 adds shell-state integration tests, and its shell-state job fails against zi main with inner-compinit-broken while passing against this candidate. ADR-0016 records that for zi, main is the ref consumers pull directly and staleness has a direct, ongoing cost.

Contents

Five user-facing fixes and one behavioural test addition:

Commit Effect
#472 autoload substitution no longer claims functions the loading plug-in does not own (fixes #471)
#477 removes an FPATH assignment from the +X branch; see #491 below
#478 autoload'' ice and @autoload work again
#479 the enclosing plug-in is restored after a nested load
#481 substitutions install and remove once, counted by nesting depth
#491 restores the caller's $fpath in the +X branch, fixing the regression #477 introduced
#493 covers the -w branch's persistent $fpath append

The remainder is CI and repository work: #470, #474, #485, #487, #490, and one main reconciliation.

Note the #477 and #491 pairing. #477 removed a line as dead that was not, breaking immediate autoload +X of any function a plug-in does not own; #491 restores the behaviour. Both are inside this candidate, so the net effect of the pair against prior main is the intended fix plus regression coverage, not a live defect.

Unresolved issues

None blocking. Open zi issues at the time of writing are #113, #386, #387, #388, #429, #430, #445, #446, #447, #448, #449, #468, #482, #483. All predate this candidate or are unrelated to it, and none is a regression introduced by it.

User-facing and migration notes

No migration required. Every behavioural change in this candidate restores intended behaviour that regressed; none changes a documented interface. Users on stable gain working completions in plug-in-body compinit arrangements, working autoload'' ice and @autoload, correct enclosing-plug-in state after nested loads, and correct substitution install/remove pairing.

Public-contract follow-ups

None identified. The Public contract impact check ran on each constituent pull request and reported no consumer-visible surface change requiring documentation.

  • If the candidate introduces monitoring for a contract surface absent from the base, manually inventory that surface across the complete compare.

Merge contract

Promotion uses Create a merge commit. Never squash or rebase.

  • The resulting commit message has no bot, AI-agent, or automation Co-authored-by trailer.
  • delete_branch_on_merge is false; remote next will survive the merge.
  • The main ruleset allows only merge commits and does not require linear history.
  • The next ruleset does not require linear history.

Rollback readiness

  • Rollback owner: @ss-o
  • Observable rollback criteria:
    • A plug-in that loaded on v2.0.0 fails to load, or a completion that worked stops working, at the new main head.
    • z-shell/zd shell-state or any other ZD ZUnit job fails against the new main.
  • The owner can open a same-repository hotfix-* pull request that reverts the promotion merge through protected main.
  • The revert pull request will run Guard main branch source and Promotion gate.
  • After a revert, clean install and startup will be confirmed at the new main head, then the hotfix will be merged forward into next.

Stable consumption boundary

This updates the Git-consumed stable main ref. It creates no semantic tag or GitHub release. v2.0.0 remains the newest tag; any tag covering this content is a separately approved action.

ss-o and others added 13 commits August 31, 2026 06:37
Co-authored-by: Sal <ss-o@users.noreply.github.com>
`.zi-tmp-subst-on` replaces the `autoload' builtin for the whole duration of a
plug-in's load so that a plug-in can autoload its own functions without its
directory being in $fpath. The replacement wrote a stub with $PLUGIN_DIR baked
into the body without ever checking that the function actually lives there, so
every `autoload' issued while the substitution was installed got the currently
loading plug-in's directory baked in, whoever the function belonged to.

The usual source of such calls is compinit, which replays a bulk `autoload -Uz'
for every completion function recorded in .zcompdump. Functions whose real
provider directory was not yet in $fpath, most commonly because the provider is
loaded later, were left pointing at the wrong directory and failed with
`function definition file not found' on first call.

Look the function up under the plug-in's own directory and its own $fpath
entries first, the same ownership test the -C branch already performs. When it
is not found there, hand the call to `builtin autoload', which resolves lazily
against the live $fpath and therefore succeeds once the real provider is loaded.
The -C form arrives only from an explicit autoload'' ice, so it keeps its own
search and is byte-identical.

Three cases needed the ownership test to recognise directories that are in fact
the plug-in's own, each previously masked because every consumer appended the
global $fpath afterwards:

- `(R)' rather than `(r)' for $fpath_elements, so a plug-in registering more
  than one $fpath subdirectory does not have all but the first hidden.
- ${PLUGIN_DIR:A} matched as well as $PLUGIN_DIR, because the Plug Standard
  idiom `fpath+=( ${0:A:h}/lib )' resolves symlinks while $PLUGIN_DIR keeps the
  path zi was given.
- `-f $apth.zwc' accepted alongside `-f $apth/$func', because an $fpath entry
  may be backed by a directory digest rather than by plain files.

tests/plugin-autoload-ownership.zsh covers all five behaviours and is registered
in zsh-n.yml. Each of the three recognition hunks was confirmed load-bearing by
reverting it in isolation.

Closes #471
fix(autoload): drop the dead FPATH assignment in the +X branch

The `+X' branch opened with

    local +h FPATH=$PLUGINS_DIR${fpath_elements:+:...}:$FPATH

built from $PLUGINS_DIR, which has never existed; the plug-in directory is
$PLUGIN_DIR and the plug-ins root is ZI[PLUGINS_DIR]. The value was never used
either: `local +h -a fpath' on the next line declares a fresh local array, and
because fpath and FPATH are tied that discards the scalar, after which
`fpath=( $PLUGIN_DIR $fpath_elements $fpath )' establishes the real search path.

Tracing the branch confirms it. FPATH immediately before `builtin autoload +X'
carries no empty leading field and does not contain the working directory, and
removing the assignment changes nothing observable: immediate autoload still
resolves the plug-in's own functions, and neither $fpath nor $FPATH leaks to the
caller, because the array declaration localises both.

Remove it rather than repair the name. It is inert, it names a parameter that
does not exist, and it sits in the middle of the search-path construction it
appears to belong to, which has already caused one misdiagnosis.

tests/plugin-autoload-fpath-scope.zsh pins the invariant that made the removal
safe: immediate `autoload +X' resolves from the plug-in directory and from an
$fpath subdirectory the plug-in registers, never from the working directory, and
restores the caller's $fpath and $FPATH. It passes before and after, which is
the point.

Closes #475
fix(autoload): make the autoload'' ice and @autoload work again

Two independent defects left every form of the ice broken except the plain and
`#' ones.

The (#b) backreference substitution that rewrites `a -> b' into `a -S b' needs
extended_glob. Neither .zi-load-plugin nor @autoload sets it, so the
substitution matched nothing and the value reached :zi-tmp-subst-autoload
untranslated: the unspaced form arrived as one literal name, and the spaced form
word-split into three, `a', `->' and `b'. The -C flag was still added, because
plain alternation does not need extended_glob, so the -C branch then searched
$fpath for names that cannot exist. Build both argument lists inside an
anonymous function that sets the option and localises match, mbegin and mend.

The -C branch's generated wrapper installed the loaded body under the wrong key.
The call line reads ${(q)${custom[count*2]}:-$func}, which falls back to $func
when the element is unset, but the assignment line read
${${(q)custom[count*2]}:-$func}, which quotes first; quoting an unset element
yields a two-character string, so the :- fallback never fired and the wrapper
assigned to functions['']. The real function was never redefined, so calling it
re-entered the wrapper until FUNCNEST. Match the call line's nesting.

The two are not independent in effect. Once the substitution runs, every name
also gets `-S <name>', so custom is always populated and the empty key stops
arising; either fix alone resolves the bang form. The nesting fix is kept
because the assignment is wrong on its own terms.

tests/plugin-autoload-ice.zsh covers the plain, spaced rename, unspaced rename
and bang forms of the ice, plus @autoload in its plain and rename forms, and is
registered in zsh-n.yml. Each ice form needs its own plug-in directory and its
own function name, because zi skips an already-loaded plug-in and the
substitution never touches a function that already exists.

Closes #476
fix(load): restore the enclosing plug-in after a nested load

.zi-load and .zi-load-snippet set ZI[CUR_USR], ZI[CUR_PLUGIN] and
ZI[CUR_USPL2] on entry and cleared them unconditionally on exit, under the
comment "mark no load is in progress". Both are re-entrant: a plug-in body may
call `zi load', `zi light' or `zi snippet'. The inner call cleared the state the
outer call still needed, so everything zi did on the outer plug-in's behalf
after a nested load was attributed to no plug-in at all, including report
entries and the bindkey, zstyle, alias and widget tracking keys used by unload.

Save the previous values and restore them instead. At the top level the saved
values are empty, so "no load is in progress" still holds there.

Restore from an `always' block rather than at the old assignment site. Both
functions return early on several paths, cloneonly'' among them, and a plain
restore at the end would leave those paths clobbering the outer plug-in exactly
as before. `always' also matches how the rest of the file handles this shape.

tests/nested-load-state.zsh covers three nesting shapes, a plain nested plug-in
load, one that returns early through cloneonly'', and a nested snippet, and
asserts the top level is still clear afterwards. It is registered in zsh-n.yml.

This does not make the autoload in the issue's reproduction resolve. That
symptom has a second, independent cause: .zi-tmp-subst-on returns early when a
substitution is already installed, while .zi-tmp-subst-off tears it down as
soon as a nested load of the same mode finishes, so the outer plug-in's later
`autoload' calls are no longer intercepted at all. Reported separately.

Refs #473
…ce (#481)

fix(subst): count load nesting so substitutions install and remove once

ZI[TMP_SUBST] carried two incompatible encodings. .zi-tmp-subst-on stored a
mode name in it, while three inlined compdef-only blocks in .zi-load-snippet
and .zi-load-plugin used it as a depth counter, with `ZI[TMP_SUBST]=1',
`(( ++ ZI[TMP_SUBST] ))' and `(( -- ZI[TMP_SUBST] == 0 ))'.

Loading is re-entrant, so the two met. Two failures followed.

A plug-in that loads another plug-in hit the on/off pair: the inner
.zi-tmp-subst-on returned early because a substitution was already installed,
then the inner .zi-tmp-subst-off matched on mode and tore it down while the
outer plug-in was still running. Everything the outer plug-in did afterwards
stopped being intercepted, including FPATH-clean autoloading and the bindkey,
zstyle, alias, zle and compdef tracking that report and unload depend on.

A plug-in that loads a snippet hit the inlined blocks. `(( ++ ZI[TMP_SUBST] ))'
on the string `load' evaluates it as an unset parameter name, so the mode
became 1, the matching decrement reached 0, and the compdef teardown ran and
set the key to inactive. The outer .zi-tmp-subst-off then returned early on
`inactive', so autoload, bindkey, zstyle and alias were never restored and
stayed replaced by zi's substitutions at the top level for the rest of the
shell's life.

Give the depth its own key. ZI[TMP_SUBST] keeps meaning the installed mode or
inactive, ZI[TMP_SUBST_DEPTH] counts entries. Every site that installs
increments, every site that tears down decrements, the substitutions are
installed on the transition out of inactive and removed only when the count
returns to zero. No site returns between its install and its teardown, so the
pairing is exact; that was checked before choosing counting over an always
block.

tests/subst-nesting.zsh asserts that neither a nested plug-in load nor a nested
snippet load leaks a substitution to the top level, that the depth returns to
zero, and that a plug-in's own autoload still resolves after it has loaded
another plug-in. It tests for a body dispatching to :zi-tmp-subst-*, not for
the mere presence of the function, because compdef legitimately exists once
compinit has run.

This also completes the reproduction in #473, which #479 fixed only halfway:
the enclosing plug-in keeps its identity and its substitutions.

Closes #480
Closes #473
copilot/ and codex/ join dependabot/ and renovate/ in the always-allowed
prefixes, because a coding agent picks those names rather than the
pull-request author. The pattern becomes
^(feature|bug|hotfix)-[1-9][0-9]*(-[a-z0-9]+)*$, so a descriptive slug may
follow the issue id.

The issue id stays mandatory for author-chosen branches and next keeps its
persistent-integration exemption, so decisions/0019 is unchanged in substance.

Parent: z-shell/.github#590

Closes #484
Validate Commits matched the disallowed trailer as a pipeline under pipefail
with grep -q. grep -q exits on its first match, so for a commit message larger
than the pipe buffer git was still writing, took SIGPIPE, and returned 141.
pipefail promoted that to the pipeline result and the if turned a real match
into a miss, so the banned trailer passed.

Use grep -c with a numeric test. grep -c reads to end of input, so the writer
always finishes and no SIGPIPE arises, and it keeps grep's own ERE engine and
-i semantics rather than swapping in bash matching.

Verified on four real commits: a 3 MB message carrying a bot trailer reports
clean under the old construct and flagged under the new one, while a small bot
trailer, a large human trailer, and a large clean message are unchanged.

Closes #486
z-shell/.github decisions/0022 moves traceability off the branch name and onto
the pull request. This repository carries the same commit-lint jobs with their
patterns inlined, so it moves in step or the two copies drift.

Validate Branch Name becomes a shape check: <type>-<id> with an optional slug,
which stays valid and recommended, or <type>/<slug> over the Conventional
Commits types plus feature, bug and hotfix. next keeps its
persistent-integration exemption.

Validate Issue Link is added, matching the organization copy. It passes on an
automation branch, next, a meta:no-issue label, or an issue reference in the
body, and says which applied. It reads only the pull_request event payload, so
it needs no token.

code is not in the Conventional Commits type set, so code/ branches remain
rejected here as in the organization repository. meta:no-issue reaches this
repository through label sync; until then the exemption path is unusable and
the check falls through to the body reference.

Closes #489
Co-authored-by: Sal <ss-o@users.noreply.github.com>
Co-authored-by: Sal <ss-o@users.noreply.github.com>
@ss-o
ss-o requested a review from a team as a code owner September 3, 2026 00:14
@ss-o
ss-o merged commit 8675819 into main Sep 3, 2026
161 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

autoload shadow claims functions the loading plugin does not own

1 participant