Skip to content

fix(install): layer-units red on macOS bash 3.2 — guard array expansions + un-export test scope arrays - #989

Merged
artyhoo merged 1 commit into
stagingfrom
fix/layer-units-bash32-empty-arrays
Jul 11, 2026
Merged

fix(install): layer-units red on macOS bash 3.2 — guard array expansions + un-export test scope arrays#989
artyhoo merged 1 commit into
stagingfrom
fix/layer-units-bash32-empty-arrays

Conversation

@artyhoo

@artyhoo artyhoo commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

tests/install-sh/layer-units.test.sh was red on staging HEAD when run locally on macOS — units 20-agents.sh and 99-finalize.sh failed to source in the minimal dispatcher scope. Diagnosis: not a recent regression — two independent bash-3.2-only defects, latent since the S1 modularization (1c08b1627, #719), verified by re-running the probe against the S1-era file versions (identical failures). CI never saw them because ubuntu bash ≥ 4.4 expands empty/unset arrays under set -u without error; macOS /bin/bash 3.2.57 aborts with unbound variable.

  1. Layer defect — unguarded array expansions: setup.d/20-agents.sh:56 "${SHIPPED_DOCS[@]}", setup.d/99-finalize.sh:383+386 "${DEVDEPS[*]}" / "${RUNTIME_DEPS[*]}" — violating the repo's own bash-3.2-safety convention (setup.d/lib.sh:281 comment; setup.d/60-ci.sh:156 idiom).
  2. Test-harness defect_setup_dispatcher_scope used export SHIPPED_DOCS=() (+ SKIPPED, DEVDEPS). On bash 3.2 an export+array assignment inside a function is silently function-local, so the arrays were UNSET (not empty) in the layer-sourcing subshell — even the documented-safe ${#SKIPPED[@]} length check at setup.d/99-finalize.sh:344 crashed.

Both sides were wrong, so both are fixed: layers get the established guards (${arr[@]+"${arr[@]}"} loop guard, ${arr[*]-} default-empty); the test scope assigns arrays plainly (bash cannot export arrays at all; layers are sourced in-shell, so export was inert on bash ≥ 4.4 and harmful on 3.2).

Real consumer installs were never affected: install.sh:103 (SKIPPED), install.sh:129 (SHIPPED_DOCS), setup.d/70-deps.sh:209/226 (DEVDEPS/RUNTIME_DEPS) initialize all four arrays non-empty at top level before the affected expansions run.

Changes

  • setup.d/20-agents.sh — §3c SHIPPED_DOCS loop: bash-3.2-safe ${arr[@]+"${arr[@]}"} guard (60-ci.sh:156 idiom).
  • setup.d/99-finalize.sh — step-4 fallback printfs: "${DEVDEPS[*]-}" / "${RUNTIME_DEPS[*]-}" (default-empty; byte-identical output when populated).
  • tests/install-sh/layer-units.test.sh_setup_dispatcher_scope: array vars use plain assignment instead of export arr=() (bash-3.2 function-local export bug), with an explanatory comment.

Failing → green evidence

Before (staging HEAD 11adac392, macOS /bin/bash 3.2.57):

  ✗ 20-agents.sh: source failed (last line:   [dry-run] would copy: …/agents/rule-researcher.md → …)
  ✗ 99-finalize.sh: source failed (last line:   [dry-run] would check @opentelemetry/* vs AIF_STRICT_RUNTIME …)
PASS=25 FAIL=2   (suite exit 1)

Underlying errors (stderr un-suppressed):

setup.d/20-agents.sh: line 67: SHIPPED_DOCS[@]: unbound variable
setup.d/99-finalize.sh: line 383: DEVDEPS[*]: unbound variable
setup.d/99-finalize.sh: line 344: SKIPPED: unbound variable   (after fixing 383 — the harness-side defect)

After (this branch):

PASS=27 FAIL=0   (suite exit 0)   — bash 3.2.57, macOS
PASS=27 FAIL=0   (suite exit 0)   — bash 5.3.9, linux container (CI parity)

Regression battery run locally: byte-identical.test.sh 8/8 combinations unchanged + shellcheck clean (0.11.0); f8-agents-scripts-shipped.test.sh PASS=8 FAIL=0; tool-decisions-seed-integration.test.sh PASS=7 FAIL=0.

Historical probe (same minimal-scope sourcing against git show <sha>:setup.d/<layer>): failures reproduce identically at 1c08b1627 (S1), 6143327e0, 02239d0b9, e159bf5e5, 11adac392 → red since S1, not a recent regression.

Backward sweep of the defect class

All "${arr[@]}"/"${arr[*]}" sites in setup.d/*.sh + install.sh enumerated (grep -nE '\$\{[A-Za-z_0-9]+\[[@*]\]'), each verdicted:

  • SWEPT-CLEAN (guarded or set+non-empty by construction): setup.d/60-ci.sh:225-230 (_aif_missing/_aif_steps init :108-109, populated in tandem :119, guarded :220), setup.d/99-finalize.sh:160 (_l2_configs init :148, non-empty branch), setup.d/99-finalize.sh:351 (guarded :344), setup.d/70-deps.sh:209-358 (literals defined just above use), install.sh:160+541 (SHIPPED_DOCS non-empty literal :129), setup.d/lib.sh:241/252/263 (missing early-return :214), lib.sh:284 (guarded :283), lib.sh:362 (fresh early-return :354).
  • GAP-FOUND (latent, out of scope — surfaced below): setup.d/lib.sh:348 (candidates can be empty if zero skill files ship — unreachable for current stacks); tests/install-sh/tool-decisions-seed-integration.test.sh:34-38 (same export arr=() in-function pattern; its exercised path doesn't hit the arrays — suite currently PASS=7 FAIL=0).

Observations (not fixed here — single-concern scope)

  1. setup.d/lib.sh:348for rel in "${candidates[@]}" would crash on bash 3.2 if ignore_shipped_configs ever runs with zero shipped skill .md files (not reachable for any current stack/preset).
  2. tests/install-sh/tool-decisions-seed-integration.test.sh:34-38 — same export SHIPPED_DOCS=()-in-function pattern as the harness bug fixed here; currently green because its path doesn't expand those arrays. Same one-line class of fix if invited.

Prior-art consult

  • No capability commits in this PR (bug fix: no new dependency, no new file, 3 modified files / +14−6) — Prior-art: trailer not required; pre-push hook did not flag.
  • No new capability area surfaced — no SSOT entry needed.
  • N/A — no existing SSOT entries matched (no capability).
  • N/A — no new capability area, context7 consult not applicable.

Test plan

  • bash tests/install-sh/layer-units.test.sh — PASS=27 FAIL=0, exit 0 (bash 3.2.57 macOS + bash 5.3.9 linux)
  • bash tests/install-sh/byte-identical.test.sh — 8/8 byte-identical, shellcheck clean
  • bash tests/install-sh/f8-agents-scripts-shipped.test.sh — PASS=8 FAIL=0
  • bash tests/install-sh/tool-decisions-seed-integration.test.sh — PASS=7 FAIL=0
  • shellcheck --exclude=SC2034,SC2016,SC2317 setup.d/*.sh install.sh — clean (CI gate mirror)
  • pre-push hooks green on push (46 core tests passed)
  • §1.7-свод lands in squash-body (gh pr merge --squash --body "$(gh pr view <N> --json body -q .body)")

§1.7 Self-discipline check (REQUIRED if PR touches discipline-bearing files)

§1.7 Skipped: bug fix to installer shell layers + test harness (bash-3.2 empty-array safety); introduces or extends no discipline rule, principle, or shipped template.

…ons + un-export test scope arrays

Two independent bash-3.2-only defects, latent since the S1 modularization
(1c08b16, #719) — surfaced 2026-07-11 by the first full local run of
tests/install-sh on macOS. CI never saw them: ubuntu bash >= 4.4 expands
empty/unset arrays under `set -u` without error; macOS /bin/bash 3.2.57
aborts with "unbound variable".

1. Layers: setup.d/20-agents.sh:56 `"${SHIPPED_DOCS[@]}"` and
   setup.d/99-finalize.sh:383+386 `"${DEVDEPS[*]}"` / `"${RUNTIME_DEPS[*]}"`
   were unguarded — crashing under set -u on bash 3.2 whenever the array is
   empty or unset, violating the repo's own bash-3.2-safety convention
   (setup.d/lib.sh:281 comment; setup.d/60-ci.sh:156 idiom). Fixed with the
   established guards: `${arr[@]+"${arr[@]}"}` loop guard + `${arr[*]-}`
   default-empty. Output is byte-identical when arrays are populated
   (byte-identical gate: 8/8 combinations unchanged).

2. Test harness: layer-units.test.sh `_setup_dispatcher_scope` used
   `export SHIPPED_DOCS=()` (+ SKIPPED, DEVDEPS). On bash 3.2 an
   export+array assignment inside a function is silently function-local —
   the arrays were UNSET (not empty) in the layer-sourcing subshell, so even
   the documented-safe `${#SKIPPED[@]}` length check at 99-finalize.sh:344
   crashed. Arrays now use plain assignment: bash cannot export arrays at
   all, and layers are sourced into the same shell, so export was inert on
   bash >= 4.4 and harmful on 3.2.

Real consumer installs were never affected: install.sh:103 (SKIPPED),
install.sh:129 (SHIPPED_DOCS), setup.d/70-deps.sh:209/226
(DEVDEPS/RUNTIME_DEPS) initialize all four arrays non-empty at top level
before the affected expansions run.

Evidence: layer-units PASS=25 FAIL=2 (suite exit 1) -> PASS=27 FAIL=0
(exit 0) on bash 3.2.57; PASS=27 FAIL=0 on bash 5.3.9 (linux container,
CI parity); byte-identical 8/8 green; shellcheck clean;
f8-agents-scripts-shipped PASS=8 FAIL=0.
@artyhoo
artyhoo merged commit 52ecd6e into staging Jul 11, 2026
36 checks passed
@artyhoo

artyhoo commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

Follow-up verification (2026-07-11): both Observations WITHDRAWN as false positives.

  1. setup.d/lib.sh candidates loop — NOT a latent crash. The sweep window started below the initializer: local candidates=( "eslint.config.mjs" … ) (lib.sh:297-301) seeds the array with 10 static literals, so it is non-empty by construction at the loop. Same safe-by-construction class as the 70-deps.sh sites verdicted SWEPT-CLEAN above.

  2. tests/install-sh/tool-decisions-seed-integration.test.sh:34-38 — NOT the same landmine. The bash-3.2 export arr=() quirk is strictly function-local: at subshell top level (where this test does it) bash 3.2 creates the array correctly (verified: declare -ax A='()', ${#A[@]} → 0). Additionally 30-templates.sh never element-expands SHIPPED_DOCS/SKIPPED/DEVDEPS (grep: zero hits), so the suite's PASS=7 is legitimate, not route-luck. Left as-is per single-concern discipline — normalizing export → plain assignment there would be cosmetic churn on a green test. (It only becomes load-bearing if that block is ever copied into a function — the layer-units comment shipped in this PR documents the trap.)

No follow-up PR needed.

artyhoo added a commit that referenced this pull request Jul 11, 2026
…at pre-push (#990)

* fix(drift-gate): shipped-eslint-rules --check detects orphans + runs at pre-push

Two deviations from the drift-gate invariant (cold backward-sweep,
2026-07-11, python-delivery-v0 S1):

1. No orphan detection — the check loop iterated only from .ts sources,
   so deleting a rule source left its committed .mjs/.d.ts silently
   shipping. Added an orphan walk (committed artifact without a matching
   shippable source = RED), mirroring the python-template drift gate's
   committed-artifacts-without-source walk.
2. CI-only channel — the gate ran solely in audit-self.yml, one channel
   later than its pre-push siblings (synth-bundle --check, manifest
   render). Wired as pre-push section 3g (exit 2 = tsc absent → degrade
   to warn-skip, mirroring 3f's esbuild skip).

RED-then-GREEN evidence: with require-error-boundary.ts moved aside,
--check emits ORPHAN for both .mjs and .d.ts and exits 1; restored,
exits 0 (24 checks). Verified under macOS bash 3.2 (#989 precedent) and
npm --prefix packages/core run test:hooks (716/716 green).

Prior-art: skipped — drift-gate hardening of an existing script + pre-push wiring; no new dependency, module, or capability

* test(install-sh): regen byte-identical baselines — pre-push.ts hash shifted by section 3g

Only delta across all 8 fingerprints is the packages/core/hooks/pre-push.ts
sha (shipped file). SNAPSHOT_MODE=capture + local compare 8/8 green.

Prior-art: skipped — snapshot baseline regeneration after shipped-file edit, no new capability

---------

Co-authored-by: t <t@t.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants