From d01d99feac8870415c60ee0a2a03f792f3c899bd Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:37:28 +0100 Subject: [PATCH 01/12] feat(release): automate approved milestone publication (#470) --- .github/workflows/release-prepare.yml | 21 ++++++++ .github/workflows/release.yml | 48 +++++++++++++++++ scripts/verify-release-tag.zsh | 55 +++++++++++++++++++ tests/release-tag-verification.zsh | 77 +++++++++++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 .github/workflows/release-prepare.yml create mode 100644 .github/workflows/release.yml create mode 100755 scripts/verify-release-tag.zsh create mode 100755 tests/release-tag-verification.zsh diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000..cd15c77 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,21 @@ +--- +name: Release Prepare + +on: + push: + branches: [main] + +permissions: + contents: read + issues: write + models: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + propose: + uses: z-shell/.github/.github/workflows/release-prepare.yml@6f3d88335ca0ae77b795ec2883b4402b51f15c6a # main + with: + signed_tag: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..00ee8cd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +--- +name: Release + +on: + push: + tags: ["v*.*.*"] + +permissions: + actions: read + contents: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Verify and publish + if: github.repository == 'z-shell/zi' + runs-on: ubuntu-latest + steps: + - name: Check out the tagged commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Verify release authorization + env: + GH_TOKEN: ${{ github.token }} + run: zsh -f scripts/verify-release-tag.zsh + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists." + exit 0 + fi + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "Zi $TAG" \ + --generate-notes \ + --latest diff --git a/scripts/verify-release-tag.zsh b/scripts/verify-release-tag.zsh new file mode 100755 index 0000000..c6dcd30 --- /dev/null +++ b/scripts/verify-release-tag.zsh @@ -0,0 +1,55 @@ +#!/usr/bin/env zsh + +emulate -L zsh +setopt err_return no_unset pipe_fail + +fail() { + print -u2 -- "release verification: $*" + return 1 +} + +tag=${GITHUB_REF_NAME:-} +repository=${GITHUB_REPOSITORY:-} + +[[ $repository == z-shell/zi ]] || fail "unexpected repository: ${repository:-unset}" +[[ $tag =~ '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' ]] || + fail "tag must match vX.Y.Z: ${tag:-unset}" + +tag_ref="refs/tags/${tag}" +[[ $(git cat-file -t "$tag_ref" 2>/dev/null) == tag ]] || + fail "tag must be annotated: $tag" + +git fetch --quiet --force --no-tags origin \ + refs/heads/main:refs/remotes/origin/main || + fail "could not fetch origin/main" + +target=$(git rev-parse "${tag_ref}^{}") || fail "could not resolve tag target" +main=$(git rev-parse refs/remotes/origin/main) || fail "could not resolve origin/main" +[[ $target == $main ]] || fail "tag target is not the current origin/main" + +tag_object=$(git rev-parse "$tag_ref") || fail "could not resolve tag object" +tag_json=$(gh api "repos/${repository}/git/tags/${tag_object}") || + fail "could not read tag verification" +jq -e --arg target "$target" \ + '.verification.verified == true and + .object.type == "commit" and + .object.sha == $target' <<<"$tag_json" >/dev/null || + fail "GitHub did not verify the signed tag and target" + +runs_json=$(gh api --method GET "repos/${repository}/actions/runs" \ + -f branch=main -f head_sha="$target" -f per_page=100) || + fail "could not read workflow runs" + +for workflow in Zsh 'ZD Integration' CodeQL 'Trunk Code Quality'; do + jq -e --arg name "$workflow" --arg target "$target" \ + '.workflow_runs | any( + .name == $name and + .head_branch == "main" and + .head_sha == $target and + .status == "completed" and + .conclusion == "success" + )' <<<"$runs_json" >/dev/null || + fail "required workflow did not succeed: $workflow" +done + +print -- "Release authorization verified for ${tag} at ${target}." diff --git a/tests/release-tag-verification.zsh b/tests/release-tag-verification.zsh new file mode 100755 index 0000000..4443780 --- /dev/null +++ b/tests/release-tag-verification.zsh @@ -0,0 +1,77 @@ +#!/usr/bin/env zsh + +emulate -L zsh +setopt err_exit no_unset pipe_fail + +root=${0:A:h:h} +tmp=$(mktemp -d "${TMPDIR:-/tmp}/zi-release-test.XXXXXX") +trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM + +git init --bare "$tmp/origin.git" >/dev/null +git clone "$tmp/origin.git" "$tmp/repository" >/dev/null 2>&1 +git -C "$tmp/repository" config user.email release-test@example.invalid +git -C "$tmp/repository" config user.name 'Release Test' +print test >"$tmp/repository/file" +git -C "$tmp/repository" add file +git -C "$tmp/repository" commit -m 'test: initial commit' >/dev/null +git -C "$tmp/repository" branch -M main +git -C "$tmp/repository" push -u origin main >/dev/null 2>&1 + +target=$(git -C "$tmp/repository" rev-parse HEAD) +git -C "$tmp/repository" tag -a v2.1.0 -m v2.1.0 +git -C "$tmp/repository" tag v2.1.1 + +print stale >>"$tmp/repository/file" +git -C "$tmp/repository" commit -am 'test: advance main' >/dev/null +git -C "$tmp/repository" push origin main >/dev/null 2>&1 +current=$(git -C "$tmp/repository" rev-parse HEAD) +git -C "$tmp/repository" tag -a v2.2.0 -m v2.2.0 + +mkdir "$tmp/bin" +cat >"$tmp/bin/gh" <<'FAKE_GH' +#!/usr/bin/env zsh +if [[ $* == *'/git/tags/'* ]]; then + print -r -- "{\"verification\":{\"verified\":${FAKE_TAG_VERIFIED}},\"object\":{\"type\":\"commit\",\"sha\":\"${FAKE_TAG_TARGET}\"}}" + exit 0 +fi +conclusion=success +[[ ${FAKE_WORKFLOW_FAILURE:-false} == true ]] && conclusion=failure +print -r -- "{\"workflow_runs\":[ +{\"name\":\"Zsh\",\"head_branch\":\"main\",\"head_sha\":\"${FAKE_TAG_TARGET}\",\"status\":\"completed\",\"conclusion\":\"${conclusion}\"}, +{\"name\":\"ZD Integration\",\"head_branch\":\"main\",\"head_sha\":\"${FAKE_TAG_TARGET}\",\"status\":\"completed\",\"conclusion\":\"success\"}, +{\"name\":\"CodeQL\",\"head_branch\":\"main\",\"head_sha\":\"${FAKE_TAG_TARGET}\",\"status\":\"completed\",\"conclusion\":\"success\"}, +{\"name\":\"Trunk Code Quality\",\"head_branch\":\"main\",\"head_sha\":\"${FAKE_TAG_TARGET}\",\"status\":\"completed\",\"conclusion\":\"success\"} +]}" +FAKE_GH +chmod +x "$tmp/bin/gh" + +run_verifier() { + local tag=$1 verified=$2 api_target=$3 workflows_fail=${4:-false} + ( + cd "$tmp/repository" + PATH="$tmp/bin:$PATH" \ + GITHUB_REF_NAME=$tag \ + GITHUB_REPOSITORY=z-shell/zi \ + FAKE_TAG_VERIFIED=$verified \ + FAKE_TAG_TARGET=$api_target \ + FAKE_WORKFLOW_FAILURE=$workflows_fail \ + zsh -f "$root/scripts/verify-release-tag.zsh" + ) +} + +expect_fail() { + if run_verifier "$@" >/dev/null 2>&1; then + print -u2 -- "expected verification to fail: $1" + return 1 + fi +} + +expect_fail release-2.2.0 true "$current" +expect_fail v2.1.1 true "$target" +expect_fail v2.1.0 true "$target" +expect_fail v2.2.0 false "$current" +expect_fail v2.2.0 true "$target" +expect_fail v2.2.0 true "$current" true +run_verifier v2.2.0 true "$current" + +print 'release tag verification tests passed' From 3dce1e8b526369efa746ff76c9421387b7ec28dd Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:23:53 +0100 Subject: [PATCH 02/12] chore(deps): retire Dependabot routine updates (#474) Co-authored-by: Sal --- .github/dependabot.yml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index e634d3b..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,17 +0,0 @@ -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "05:30" - timezone: "UTC" - target-branch: "next" - open-pull-requests-limit: 5 - groups: - github-actions: - patterns: - - "*" From fe5ee808c3c8911eb133484776b8de0254f14c87 Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:04:57 +0100 Subject: [PATCH 03/12] fix(autoload): only claim functions owned by the loading plug-in (#472) `.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 --- .github/workflows/zsh-n.yml | 13 ++ tests/plugin-autoload-ownership.zsh | 196 ++++++++++++++++++++++++++++ zi.zsh | 35 ++++- 3 files changed, 241 insertions(+), 3 deletions(-) create mode 100755 tests/plugin-autoload-ownership.zsh diff --git a/.github/workflows/zsh-n.yml b/.github/workflows/zsh-n.yml index b117eb4..2858c69 100644 --- a/.github/workflows/zsh-n.yml +++ b/.github/workflows/zsh-n.yml @@ -15,6 +15,7 @@ on: - "tests/message-formatting.zsh" - "tests/path-resolution.zsh" - "tests/parallel-update.zsh" + - "tests/plugin-autoload-ownership.zsh" - "tests/plugin-standard-callbacks.zsh" - "tests/scheduler-idle.zsh" - "tests/fixtures/plugin-standard-callbacks/**" @@ -31,6 +32,7 @@ on: - "tests/message-formatting.zsh" - "tests/path-resolution.zsh" - "tests/parallel-update.zsh" + - "tests/plugin-autoload-ownership.zsh" - "tests/plugin-standard-callbacks.zsh" - "tests/scheduler-idle.zsh" - "tests/fixtures/plugin-standard-callbacks/**" @@ -117,6 +119,17 @@ jobs: - name: Test parallel update run: zsh -f tests/parallel-update.zsh + plugin-autoload-ownership: + name: Plugin Autoload Ownership + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Zsh + run: sudo apt update && sudo apt-get install -yq zsh + - name: Test plugin autoload ownership + run: zsh -f tests/plugin-autoload-ownership.zsh + plugin-standard-callbacks: name: Plugin Standard Callbacks runs-on: ubuntu-latest diff --git a/tests/plugin-autoload-ownership.zsh b/tests/plugin-autoload-ownership.zsh new file mode 100755 index 0000000..6dc09aa --- /dev/null +++ b/tests/plugin-autoload-ownership.zsh @@ -0,0 +1,196 @@ +#!/usr/bin/env zsh +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et + +builtin emulate -R zsh +setopt pipe_fail + +fail() { + builtin print -u2 -r -- "not ok - $1" + exit 1 +} + +typeset project_root="${ZI_TEST_CHECKOUT:-${0:A:h:h}}" +typeset temp_root +temp_root="$(command mktemp -d "${TMPDIR:-/tmp}/zi-autoload-test.XXXXXXXX")" || + fail "create temporary directory" +trap 'command rm -rf -- "$temp_root"' EXIT INT TERM + +command mkdir -p \ + "${temp_root}/home" \ + "${temp_root}/cache" \ + "${temp_root}/config" \ + "${temp_root}/data" \ + "${temp_root}/zdotdir" \ + "${temp_root}/plugins/registrar" \ + "${temp_root}/plugins/provider/lib" \ + "${temp_root}/plugins/selfowned" \ + "${temp_root}/plugins/multidir/lib" \ + "${temp_root}/plugins/multidir/funcs" \ + "${temp_root}/plugins/symlinked/lib" \ + "${temp_root}/plugins/digest/functions" || fail "create isolated environment" +command ln -s -- "${temp_root}/plugins" "${temp_root}/plugins-link" || + fail "create plug-in directory symlink" + +# The registrar stands in for a plug-in that runs compinit, which replays a +# bulk `autoload -Uz' for every completion function recorded in .zcompdump. +# None of those functions belong to the registrar. +builtin print -r -- 'autoload -Uz _issue_471_completion' \ + > "${temp_root}/plugins/registrar/registrar.plugin.zsh" || fail "write registrar plug-in" + +# The provider owns the function and is loaded afterwards. +builtin print -rl -- \ + '0=${(%):-%N}' \ + 'fpath+=( ${0:A:h}/lib )' \ + 'autoload -Uz _issue_471_completion' \ + > "${temp_root}/plugins/provider/provider.plugin.zsh" || fail "write provider plug-in" +builtin print -r -- 'builtin print -r -- provider-body' \ + > "${temp_root}/plugins/provider/lib/_issue_471_completion" || fail "write provided function" + +# A plug-in autoloading its own function while its directory stays out of +# $fpath. This is what the autoload substitution exists for and must keep +# working. +builtin print -r -- 'autoload -Uz _issue_471_own' \ + > "${temp_root}/plugins/selfowned/selfowned.plugin.zsh" || fail "write self-owned plug-in" +builtin print -r -- 'builtin print -r -- self-owned-body' \ + > "${temp_root}/plugins/selfowned/_issue_471_own" || fail "write self-owned function" + +# A plug-in registering two $fpath subdirectories, autoloading a function from +# the second one, under blockf'' so that its $fpath additions are reverted once +# it has loaded. The function has to stay resolvable afterwards. +builtin print -rl -- \ + '0=${(%):-%N}' \ + 'fpath+=( ${0:A:h}/lib ${0:A:h}/funcs )' \ + 'autoload -Uz _issue_471_second_dir' \ + > "${temp_root}/plugins/multidir/multidir.plugin.zsh" || fail "write multidir plug-in" +builtin print -r -- 'builtin print -r -- second-dir-body' \ + > "${temp_root}/plugins/multidir/funcs/_issue_471_second_dir" || fail "write second-dir function" + +# The same plug-in shape reached through a symlinked plug-in directory. The +# Plug Standard idiom resolves symlinks, so the registered $fpath entry does not +# share a prefix with the path zi was given. The plug-in still owns it. +builtin print -rl -- \ + '0=${(%):-%N}' \ + 'fpath+=( ${0:A:h}/lib )' \ + 'autoload -Uz _issue_471_symlinked' \ + > "${temp_root}/plugins/symlinked/symlinked.plugin.zsh" || fail "write symlinked plug-in" +builtin print -r -- 'builtin print -r -- symlinked-body' \ + > "${temp_root}/plugins/symlinked/lib/_issue_471_symlinked" || fail "write symlinked function" + +# A plug-in whose $fpath subdirectory is backed by a `.zwc' digest +# rather than by plain files. The directory itself is removed after compiling, +# which is what such a digest allows. +builtin print -r -- 'builtin print -r -- digest-body' \ + > "${temp_root}/plugins/digest/functions/_issue_471_digest" || fail "write digest function" +zsh -fc "zcompile -U -z ${(q)temp_root}/plugins/digest/functions.zwc \ + ${(q)temp_root}/plugins/digest/functions/_issue_471_digest" || fail "compile function digest" +command rm -rf -- "${temp_root}/plugins/digest/functions" || fail "remove compiled directory" +builtin print -rl -- \ + '0=${(%):-%N}' \ + 'fpath+=( ${0:A:h}/functions )' \ + 'autoload -Uz _issue_471_digest' \ + > "${temp_root}/plugins/digest/digest.plugin.zsh" || fail "write digest plug-in" + +env \ + HOME="${temp_root}/home" \ + XDG_CACHE_HOME="${temp_root}/cache" \ + XDG_CONFIG_HOME="${temp_root}/config" \ + XDG_DATA_HOME="${temp_root}/data" \ + ZDOTDIR="${temp_root}/zdotdir" \ + ZI_TEST_CHECKOUT="$project_root" \ + ZI_TEST_ROOT="$temp_root" \ + zsh -f <<'ZSH' || fail "autoload substitution claims functions the plug-in does not own" +builtin emulate -R zsh +setopt pipe_fail + +builtin source "${ZI_TEST_CHECKOUT}/zi.zsh" || return 1 +.zi-prepare-home || return 1 + +zi load "${ZI_TEST_ROOT}/plugins/registrar" >/dev/null || return 1 + +[[ ${functions[_issue_471_completion]} != *'local -a fpath'*registrar* ]] || { + builtin print -u2 -r -- "registrar's directory was baked into a function it does not own" + return 1 +} + +zi light "${ZI_TEST_ROOT}/plugins/provider" >/dev/null || return 1 + +typeset result +result="$(_issue_471_completion 2>&1)" || { + builtin print -u2 -r -- "function owned by a later plug-in failed to resolve: $result" + return 1 +} +[[ $result == provider-body ]] || { + builtin print -u2 -r -- "unexpected body resolved: $result" + return 1 +} + +# The FPATH-clean autoloading that the substitution exists for must survive. +zi light "${ZI_TEST_ROOT}/plugins/selfowned" >/dev/null || return 1 +[[ -z ${fpath[(r)${ZI_TEST_ROOT}/plugins/selfowned]} ]] || { + builtin print -u2 -r -- "self-owned plug-in directory unexpectedly present in \$fpath" + return 1 +} +result="$(_issue_471_own 2>&1)" || { + builtin print -u2 -r -- "plug-in's own function failed to autoload: $result" + return 1 +} +[[ $result == self-owned-body ]] || { + builtin print -u2 -r -- "unexpected self-owned body resolved: $result" + return 1 +} + +# Every $fpath subdirectory of the plug-in counts as the plug-in's own, not +# just the first one. +zi ice blockf +zi light "${ZI_TEST_ROOT}/plugins/multidir" >/dev/null || return 1 +[[ -z ${fpath[(r)${ZI_TEST_ROOT}/plugins/multidir/funcs]} ]] || { + builtin print -u2 -r -- "blockf did not revert the multidir plug-in's \$fpath additions" + return 1 +} +result="$(_issue_471_second_dir 2>&1)" || { + builtin print -u2 -r -- "function in the plug-in's second \$fpath subdirectory failed to autoload: $result" + return 1 +} +[[ $result == second-dir-body ]] || { + builtin print -u2 -r -- "unexpected second-dir body resolved: $result" + return 1 +} + +# A plug-in loaded through a symlinked directory owns the $fpath entries it +# registers, even though `fpath+=( ${0:A:h}/lib )' resolves the symlink and the +# resulting path shares no prefix with the path zi was given. +zi ice blockf +zi light "${ZI_TEST_ROOT}/plugins-link/symlinked" >/dev/null || return 1 +[[ -z ${fpath[(r)${ZI_TEST_ROOT:A}/plugins/symlinked/lib]} ]] || { + builtin print -u2 -r -- "blockf did not revert the symlinked plug-in's \$fpath additions" + return 1 +} +result="$(_issue_471_symlinked 2>&1)" || { + builtin print -u2 -r -- "function of a plug-in loaded through a symlink failed to autoload: $result" + return 1 +} +[[ $result == symlinked-body ]] || { + builtin print -u2 -r -- "unexpected symlinked body resolved: $result" + return 1 +} + +# An $fpath entry of the plug-in backed by a `.zwc' digest is the +# plug-in's own, even though no plain function file exists under it. +zi ice blockf +zi light "${ZI_TEST_ROOT}/plugins/digest" >/dev/null || return 1 +[[ -z ${fpath[(r)${ZI_TEST_ROOT:A}/plugins/digest/functions]} ]] || { + builtin print -u2 -r -- "blockf did not revert the digest plug-in's \$fpath additions" + return 1 +} +result="$(_issue_471_digest 2>&1)" || { + builtin print -u2 -r -- "function backed by a directory digest failed to autoload: $result" + return 1 +} +[[ $result == digest-body ]] || { + builtin print -u2 -r -- "unexpected digest body resolved: $result" + return 1 +} +ZSH + +builtin print -r -- "ok - autoload substitution only claims functions the plug-in owns" diff --git a/zi.zsh b/zi.zsh index 79cc0dd..0a06329 100644 --- a/zi.zsh +++ b/zi.zsh @@ -434,7 +434,15 @@ builtin setopt no_aliases # "Fpath elements" - ie those elements that are inside the plug-in directory. # The name comes from the fact that they are the selected fpath elements → so just "items". local -a fpath_elements - fpath_elements=( ${fpath[(r)$PLUGIN_DIR/*]} ) + # (R), not (r): (r) yields only the first match, which would hide every + # $fpath entry of a plug-in that registers more than one subdirectory. + # ${PLUGIN_DIR:A} is matched as well: the Plug Standard idiom + # `fpath+=( ${0:A:h}/lib )' resolves symlinks, while $PLUGIN_DIR keeps the + # path zi was given, so a plug-in reached through a symlinked directory would + # otherwise have all of its own $fpath entries judged foreign. Entries + # duplicated between the two matches are harmless; the array is only searched + # and prepended to the stub's own $fpath. + fpath_elements=( ${fpath[(R)$PLUGIN_DIR/*]} ${fpath[(R)${PLUGIN_DIR:A}/*]} ) # Add a function subdirectory to items, if any (this action is according to the Plug Standard version 1.07 and later). [[ -d $PLUGIN_DIR/functions ]] && fpath_elements+=( "$PLUGIN_DIR"/functions ) if (( ${+opts[(r)-X]} )); then @@ -482,8 +490,29 @@ builtin setopt no_aliases # Apply workaround func=$func:t fi - if [[ ${ZI[NEW_AUTOLOAD]} = 2 ]]; then - builtin autoload ${opts[@]} "$PLUGIN_DIR/$func" + # Only functions that live in the plug-in's own directory may get the + # FPATH-clean stub, which bakes $PLUGIN_DIR into the function body. Any + # other `autoload' issued while this plug-in is loading (most commonly + # the bulk `autoload -Uz' that a compinit run replays from .zcompdump) + # belongs to some other provider and must keep the normal, lazy $fpath + # resolution. The -C (custom name) form is requested explicitly through + # the autoload'' ice, so it keeps its own search and is left alone. + # A directory in $fpath may be backed by a `.zwc' digest + # instead of by plain files, in which case the directory itself need not + # exist; such an entry is still the plug-in's own. + local apth aowner= + for apth ( $PLUGIN_DIR $fpath_elements ) { + [[ -f $apth/$func || -f $apth.zwc ]] && { aowner=$apth; break; } + } + if [[ -z $aowner ]] && (( ! ${+opts[(r)-C]} )); then + builtin autoload ${opts[@]} -- $func + retval=$? + elif [[ ${ZI[NEW_AUTOLOAD]} = 2 ]]; then + # Unreachable while line 239 stays commented out. $aowner, not + # $PLUGIN_DIR: the owning directory can be an $fpath subdirectory of + # the plug-in. The ${aowner:-$PLUGIN_DIR} fallback keeps the -C form, + # which reaches this branch without an ownership match, unchanged. + builtin autoload ${opts[@]} "${aowner:-$PLUGIN_DIR}/$func" retval=$? elif [[ ${ZI[NEW_AUTOLOAD]} = 1 ]]; then if (( ${+opts[(r)-C]} )) { From a5a18c7dc393659375e2ce596388f35534d5f6a9 Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:44:46 +0100 Subject: [PATCH 04/12] fix(autoload): drop the dead FPATH assignment in the +X branch (#477) 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 --- .github/workflows/zsh-n.yml | 13 +++ tests/plugin-autoload-fpath-scope.zsh | 109 ++++++++++++++++++++++++++ zi.zsh | 1 - 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100755 tests/plugin-autoload-fpath-scope.zsh diff --git a/.github/workflows/zsh-n.yml b/.github/workflows/zsh-n.yml index 2858c69..886cbe2 100644 --- a/.github/workflows/zsh-n.yml +++ b/.github/workflows/zsh-n.yml @@ -15,6 +15,7 @@ on: - "tests/message-formatting.zsh" - "tests/path-resolution.zsh" - "tests/parallel-update.zsh" + - "tests/plugin-autoload-fpath-scope.zsh" - "tests/plugin-autoload-ownership.zsh" - "tests/plugin-standard-callbacks.zsh" - "tests/scheduler-idle.zsh" @@ -32,6 +33,7 @@ on: - "tests/message-formatting.zsh" - "tests/path-resolution.zsh" - "tests/parallel-update.zsh" + - "tests/plugin-autoload-fpath-scope.zsh" - "tests/plugin-autoload-ownership.zsh" - "tests/plugin-standard-callbacks.zsh" - "tests/scheduler-idle.zsh" @@ -119,6 +121,17 @@ jobs: - name: Test parallel update run: zsh -f tests/parallel-update.zsh + plugin-autoload-fpath-scope: + name: Plugin Autoload Fpath Scope + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Zsh + run: sudo apt update && sudo apt-get install -yq zsh + - name: Test plugin autoload fpath scope + run: zsh -f tests/plugin-autoload-fpath-scope.zsh + plugin-autoload-ownership: name: Plugin Autoload Ownership runs-on: ubuntu-latest diff --git a/tests/plugin-autoload-fpath-scope.zsh b/tests/plugin-autoload-fpath-scope.zsh new file mode 100755 index 0000000..6ad0afe --- /dev/null +++ b/tests/plugin-autoload-fpath-scope.zsh @@ -0,0 +1,109 @@ +#!/usr/bin/env zsh +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et + +builtin emulate -R zsh +setopt pipe_fail + +fail() { + builtin print -u2 -r -- "not ok - $1" + exit 1 +} + +typeset project_root="${ZI_TEST_CHECKOUT:-${0:A:h:h}}" +typeset temp_root +temp_root="$(command mktemp -d "${TMPDIR:-/tmp}/zi-fpath-scope-test.XXXXXXXX")" || + fail "create temporary directory" +trap 'command rm -rf -- "$temp_root"' EXIT INT TERM + +command mkdir -p \ + "${temp_root}/home" \ + "${temp_root}/cache" \ + "${temp_root}/config" \ + "${temp_root}/data" \ + "${temp_root}/zdotdir" \ + "${temp_root}/cwd" \ + "${temp_root}/plugins/plusx/lib" || fail "create isolated environment" + +# The plug-in's own functions, one directly in the plug-in directory and one in +# an $fpath subdirectory it registers. Immediate `autoload +X' of either has to +# resolve while the plug-in is loading. +builtin print -r -- 'builtin print -r -- own-body' \ + > "${temp_root}/plugins/plusx/_issue_475_own" || fail "write own function" +builtin print -r -- 'builtin print -r -- lib-body' \ + > "${temp_root}/plugins/plusx/lib/_issue_475_lib" || fail "write lib function" + +# A function file that exists only in the directory the shell happens to be in. +# The working directory is not a search path and must never be consulted. +builtin print -r -- 'builtin print -r -- cwd-body' \ + > "${temp_root}/cwd/_issue_475_cwd" || fail "write working-directory function" + +builtin print -rl -- \ + '0=${(%):-%N}' \ + 'fpath+=( ${0:A:h}/lib )' \ + 'autoload +X -Uz _issue_475_own' \ + 'autoload +X -Uz _issue_475_lib' \ + 'autoload +X -Uz _issue_475_cwd 2>/dev/null' \ + ': the third autoload is expected to find nothing' \ + > "${temp_root}/plugins/plusx/plusx.plugin.zsh" || fail "write plug-in" + +env \ + HOME="${temp_root}/home" \ + XDG_CACHE_HOME="${temp_root}/cache" \ + XDG_CONFIG_HOME="${temp_root}/config" \ + XDG_DATA_HOME="${temp_root}/data" \ + ZDOTDIR="${temp_root}/zdotdir" \ + ZI_TEST_CHECKOUT="$project_root" \ + ZI_TEST_ROOT="$temp_root" \ + zsh -f <<'ZSH' || fail "immediate autoload does not keep to the plug-in's own directories" +builtin emulate -R zsh +setopt pipe_fail + +builtin source "${ZI_TEST_CHECKOUT}/zi.zsh" || return 1 +.zi-prepare-home || return 1 + +typeset before_fpath="${(j.:.)fpath}" before_FPATH="$FPATH" +builtin cd -q "${ZI_TEST_ROOT}/cwd" || return 1 +# blockf so that the plug-in's own `fpath+=' is reverted and anything left +# behind afterwards is a leak from the substitution rather than from the +# plug-in itself. +zi ice blockf +zi load "${ZI_TEST_ROOT}/plugins/plusx" >/dev/null 2>&1 + +# The `+X' branch replaces $fpath for the duration of the call. Both the array +# and the tied scalar have to be restored for the caller. +[[ ${(j.:.)fpath} == $before_fpath ]] || { + builtin print -u2 -r -- "\$fpath leaked out of the immediate autoload" + return 1 +} +[[ $FPATH == $before_FPATH ]] || { + builtin print -u2 -r -- "\$FPATH leaked out of the immediate autoload: $FPATH" + return 1 +} + +typeset result +result="$(_issue_475_own 2>&1)" || { + builtin print -u2 -r -- "immediate autoload of the plug-in's own function failed: $result" + return 1 +} +[[ $result == own-body ]] || { + builtin print -u2 -r -- "unexpected own body resolved: $result" + return 1 +} + +result="$(_issue_475_lib 2>&1)" || { + builtin print -u2 -r -- "immediate autoload from the plug-in's \$fpath subdirectory failed: $result" + return 1 +} +[[ $result == lib-body ]] || { + builtin print -u2 -r -- "unexpected lib body resolved: $result" + return 1 +} + +[[ ${functions[_issue_475_cwd]} != *cwd-body* ]] || { + builtin print -u2 -r -- "the working directory was searched: _issue_475_cwd was loaded from \$PWD" + return 1 +} +ZSH + +builtin print -r -- "ok - immediate autoload keeps to the plug-in's own directories and restores \$fpath" diff --git a/zi.zsh b/zi.zsh index 0a06329..1ab3033 100644 --- a/zi.zsh +++ b/zi.zsh @@ -458,7 +458,6 @@ builtin setopt no_aliases fi if [[ -n ${(M)@:#+X} ]]; then .zi-add-report "${ZI[CUR_USPL2]}" "Autoload +X ${opts:+${(j: :)opts[@]} }${(j: :)${@:#+X}}" - local +h FPATH=$PLUGINS_DIR${fpath_elements:+:${(j.:.)fpath_elements[@]}}:$FPATH local +h -a fpath fpath=( $PLUGIN_DIR $fpath_elements $fpath ) builtin autoload +X ${opts[@]} "${@:#+X}" From 57b25261d2092b2a2f70e4a0566a5d3dfb175915 Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:48:49 +0100 Subject: [PATCH 05/12] fix(autoload): make the autoload'' ice and @autoload work again (#478) 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 ', 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 --- .github/workflows/zsh-n.yml | 12 ++++ tests/plugin-autoload-ice.zsh | 110 ++++++++++++++++++++++++++++++++++ zi.zsh | 34 +++++++++-- 3 files changed, 151 insertions(+), 5 deletions(-) create mode 100755 tests/plugin-autoload-ice.zsh diff --git a/.github/workflows/zsh-n.yml b/.github/workflows/zsh-n.yml index 886cbe2..6c29b2f 100644 --- a/.github/workflows/zsh-n.yml +++ b/.github/workflows/zsh-n.yml @@ -16,6 +16,7 @@ on: - "tests/path-resolution.zsh" - "tests/parallel-update.zsh" - "tests/plugin-autoload-fpath-scope.zsh" + - "tests/plugin-autoload-ice.zsh" - "tests/plugin-autoload-ownership.zsh" - "tests/plugin-standard-callbacks.zsh" - "tests/scheduler-idle.zsh" @@ -34,6 +35,7 @@ on: - "tests/path-resolution.zsh" - "tests/parallel-update.zsh" - "tests/plugin-autoload-fpath-scope.zsh" + - "tests/plugin-autoload-ice.zsh" - "tests/plugin-autoload-ownership.zsh" - "tests/plugin-standard-callbacks.zsh" - "tests/scheduler-idle.zsh" @@ -131,6 +133,16 @@ jobs: run: sudo apt update && sudo apt-get install -yq zsh - name: Test plugin autoload fpath scope run: zsh -f tests/plugin-autoload-fpath-scope.zsh + plugin-autoload-ice: + name: Plugin Autoload Ice + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Zsh + run: sudo apt update && sudo apt-get install -yq zsh + - name: Test autoload ice forms + run: zsh -f tests/plugin-autoload-ice.zsh plugin-autoload-ownership: name: Plugin Autoload Ownership diff --git a/tests/plugin-autoload-ice.zsh b/tests/plugin-autoload-ice.zsh new file mode 100755 index 0000000..f427cde --- /dev/null +++ b/tests/plugin-autoload-ice.zsh @@ -0,0 +1,110 @@ +#!/usr/bin/env zsh +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et + +builtin emulate -R zsh +setopt pipe_fail + +fail() { + builtin print -u2 -r -- "not ok - $1" + exit 1 +} + +typeset project_root="${ZI_TEST_CHECKOUT:-${0:A:h:h}}" +typeset temp_root +temp_root="$(command mktemp -d "${TMPDIR:-/tmp}/zi-autoload-ice-test.XXXXXXXX")" || + fail "create temporary directory" +trap 'command rm -rf -- "$temp_root"' EXIT INT TERM + +command mkdir -p \ + "${temp_root}/home" \ + "${temp_root}/cache" \ + "${temp_root}/config" \ + "${temp_root}/data" \ + "${temp_root}/zdotdir" \ + "${temp_root}/fns" || fail "create isolated environment" + +# zi skips a plug-in it has already loaded, and the autoload substitution never +# touches a function that already exists, so every ice form needs both its own +# plug-in directory and its own function name. +typeset case_name +for case_name ( plain spaced tight bang ) { + command mkdir -p "${temp_root}/plugins/${case_name}" || fail "create plug-in ${case_name}" + builtin print -r -- "builtin print -r -- ${case_name}-body" \ + > "${temp_root}/plugins/${case_name}/_issue_476_${case_name}" || fail "write function for ${case_name}" + builtin print -r -- ': nothing, the ice does the work' \ + > "${temp_root}/plugins/${case_name}/${case_name}.plugin.zsh" || fail "write plug-in ${case_name}" +} + +# Reachable through $fpath only, for the @autoload helper, which runs outside +# any plug-in load. +builtin print -r -- 'builtin print -r -- at-plain-body' \ + > "${temp_root}/fns/_issue_476_at_plain" || fail "write @autoload function" +builtin print -r -- 'builtin print -r -- at-rename-body' \ + > "${temp_root}/fns/_issue_476_at_src" || fail "write @autoload rename source" + +env \ + HOME="${temp_root}/home" \ + XDG_CACHE_HOME="${temp_root}/cache" \ + XDG_CONFIG_HOME="${temp_root}/config" \ + XDG_DATA_HOME="${temp_root}/data" \ + ZDOTDIR="${temp_root}/zdotdir" \ + ZI_TEST_CHECKOUT="$project_root" \ + ZI_TEST_ROOT="$temp_root" \ + zsh -f <<'ZSH' || fail "the autoload'' ice does not resolve its forms" +builtin emulate -R zsh +setopt pipe_fail + +fpath=( "${ZI_TEST_ROOT}/fns" $fpath ) +builtin source "${ZI_TEST_CHECKOUT}/zi.zsh" || return 1 +.zi-prepare-home || return 1 + +typeset result + +check() { # check