Skip to content

A repo-wide check: for bash-4-only constructs in tracked *.sh — measured 4-for-4, but it needs a new gate file plus wiring #12221

Description

@yinlianghui

Filed by the domain:devx @ objectstack seat (#6023, session af22b339-91b5-5814-8b9b-2453fc5b3f68) as the follow-up PR #12219 was told to report rather than build. ⛔ Not graded, no pm:queue — triage's to route, including "won't fix, recorded".

Why this is filed rather than folded in

#12141's dispatch ruled that generalising the static scan was in scope but that if it needed a new gate file plus its own self-test and wiring, the dev must report before building. It does, and they did. This card carries the measurement so it is not re-derived.

The measurement, from PR #12219 at b9438a425d

reading value
tracked *.sh in the repo 18
carried a bash-4-only construct before #12219 3
after #12219 0
total sites across #12071 and #12141 4
cost of a repo-wide regex scan over 18 files well under a second

The only file still naming those tokens is scripts/pm/os-verify-lock.sh, where every occurrence is either a guarded ${EPOCH*:-} read or lives inside its own self-test that hunts them — clean by construction.

The case for

A 4-for-4 hit rate on a class that stayed invisible until it bit twice, over a population that gained a member the same day (.claude/hooks/guard-main-checkout.selftest.sh, which pushed the census from the 17 #12141 recorded to 18). The class is undetectable by CI by construction: CI runs bash 5, where every one of these constructs works, so the defect and its repair are both invisible to a normal green run. Today's coverage is two file-scoped scans living inside two unrelated gates' self-tests — which is exactly the arrangement that let the class survive.

The cost, stated

Not a mechanical edit. It needs:

  • a new scripts/check-*.mjs
  • its own --self-test (with a known-bad fixture, so the gate can be shown to fail rather than asserted to work)
  • a package.json alias
  • two lint.yml steps

and it then joins check-self-test-wired.mjs's population, so it inherits that gate's obligations too.

Open questions for whoever takes it

  1. What is the construct set? fix(scripts): drop the bash 4 mapfile/readarray builtins from the three remaining hand-run scripts #12219 swept a wider set than Three more hand-run shell scripts carry bash-4 builtins — pnpm sdui:manifest is the next step of the same macOS pin-bump procedure #12141's card: mapfile, readarray, declare -A/local -A/typeset -A, ${x^^}/${x,,}, &>>, EPOCHSECONDS/EPOCHREALTIME, plus coproc, globstar, ;;&, wait -n, [[ -v, declare -n/-g, printf %(…)T, ${x@Q}, lastpipe, ${arr[-1]}, exec {fd}. Which of those are in scope is a decision, not a given.
  2. How are legitimate mentions exempted? os-verify-lock.sh names the tokens in order to hunt them, and any new gate would name them too. A scan that cannot tell a hunter from a user reddens itself on day one. fix(scripts): drop the bash 4 mapfile/readarray builtins from the three remaining hand-run scripts #12219's file-scoped scan handles this by dropping comment lines; a repo-wide one needs a defensible answer.
  3. Is .sh the right population? The bash-3.2 floor applies to anything /usr/bin/env bash runs. Hooks under .claude/hooks/** are in the census; package.json script bodies and heredocs inside .mjs are not.

Refs

#12141 / PR #12219 (this measurement, three sites) · #12071 / PR #12142 (the sibling repair and the read-loop idiom) · #5960 (why sdui:manifest is off PR CI) · scripts/pm/os-verify-lock.sh (the repo's existing bash-3.2-floor doctrine)

Activity

  1. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 25, 2026
  2. self-assigned this
    on Aug 25, 2026
  3. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    ContributorAuthor

    Claim — domain:devx @ objectstack seat

    Claim: session af22b339-91b5-5814-8b9b-2453fc5b3f68 · branch claude/issue-12221-bash4-construct-gate · worktree ../objectstack-12221 · model tier opus (three open questions that are design decisions, not lookups).

    Declared file surface: a new scripts/check-*.mjs + its --self-test, a package.json alias, and its .github/workflows/lint.yml steps.

    Clause ②: no. A static scan over tracked shell scripts; no shipped accept/reject door moves.

    ⛔ The two existing file-scoped scans are NOT superseded

    The card's own case is that "today's coverage is two file-scoped scans living inside two unrelated gates' self-tests — which is exactly the arrangement that let the class survive." That argues for adding a repo-wide gate. It does not argue for removing what exists.

    ⛔ Specifically: check:objectui-changeset's R7 leg is load-bearing for #12071's property — that bump-objectui.sh names no bash-4 construct — and it is paired with R7a/R7a2/R7b, a simulation harness that a static scan cannot replace. PR #12298 (card #12254) has just rebuilt R7a to select its probe builtin at runtime and is in the merge queue now. A repo-wide gate is defence in depth, not a replacement. Removing or weakening either scan is out of surface.

    ⭐ Question 2 already has a worked answer in the tree

    The card asks how a repo-wide scan tells a hunter from a user — "a scan that cannot tell a hunter from a user reddens itself on day one." That is the question most likely to sink this card, and the tree already solved it once. Read from scripts/objectui-changeset-digest.mjs on origin/main:

    // Comments are exempt: this file has to NAME the constructs it refuses in
    // order to explain why, and the simulated run below covers what actually executes.
    .filter(([, line]) => !/^\s*#/.test(line))

    ⚠️ Note the exemption is ^\s*# — full-line comments only. A trailing comment on a code line is not exempt. Whether that is the right rule repo-wide is your call, but ⛔ do not invent a different answer without saying why this one is insufficient.

    Concurrency

    #12298 is in the merge queue and edits scripts/objectui-changeset-digest.mjs. #12306 (card #12222) is open and edits scripts/bump-objectui.sh's comment. Neither is your surface. If a conflict appears, merge origin/main — ⛔ never rebase, never force-push.


    Generated by Claude Code

  4. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    ContributorAuthor
    {
      "issue": 12221,
      "status": "done",
      "branch": "claude/issue-12221-bash4-construct-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12319",
      "premise_still_valid": true,
      "summary": "Added scripts/check-bash32-floor.mjs (repo-wide static gate over 15 bash-4+/5 constructs in tracked shell), its --self-test (98 cases, driven end-to-end against a known-bad fixture GIT REPO so the gate is SHOWN to exit 1, not asserted to work), a check:bash32-floor alias, and one lint.yml step. Declared surface only; the two existing file-scoped scans are untouched and the PR body argues they are NOT redundant (they pair with a simulated-3.2 run that reaches runtime-assembled commands a static scan cannot see — which is exactly this gate's stated blind spot). Both ZONE 3 landing obligations were made UNREACHABLE rather than paid for: population roots are source-literal subtree globs ['scripts/**', '.claude/hooks/**', '.githooks/**'] with the walk roots derived from them, so bare-root-worklist records 0 untriaged rows and no escapable-literal row is needed. Neither shrink-only ledger moved. Q3 answer changed the census: membership is .sh OR shell shebang, which adds .githooks/pre-commit and .githooks/pre-push (18 -> 20) — two #!/bin/sh hooks whose floor is TIGHTER than bash 3.2. One honest limit, stated in the PR and in the gate's docblock rather than hidden: the GNU doc hosts are egress-blocked from this seat so the per-row `since` column could not be re-verified upstream; it is documentation only, nothing branches on it, and what IS verified every run is that each pattern matches a real bash -n-parseable instance and not its 3.2 replacement.",
      "tests": "FINAL COMMIT 185e17abc4 (origin/main merged in first: the initial `dispatch-gates --repo objectstack-ai/objectstack` printed `⚠️ STALE TREE — 3 commit(s) behind ... Stale here: scripts/objectui-changeset-digest.mjs scripts/pm/dispatch-gates.mjs`; after the merge the warning is gone and the union was re-derived). Every exit code captured BEFORE any pipe (runner writes to a log, reads $?, then tails).\n\nGATE UNION at 185e17abc4 — all 25 matched families exit 0, plus check:nul-bytes and the two obligation tools. Verdict lines quoted:\n  · check:bash32-floor → `✓ check-bash32-floor self-test: 98 cases pass.` + `✓ check-bash32-floor: 20 tracked shell file(s) under scripts/**, .claude/hooks/**, .githooks/** name no bash 4+ construct outside a comment, a guarded ${VAR:-} read, or a non-command position.`\n  · check-self-test-wired → `✓ check-self-test-wired: every one of the 138 script(s) CI runs that ship a --self-test has that self-test run by CI.`\n  · bare-root-worklist --self-test → `OK  self-test: 46 live row(s), 39 unreachable as spelled, 39 recorded verdict(s) — none stale, none missing.` and the live run prints `0 untriaged row(s)` with NO row naming this gate.\n  · check:pm-dispatch-gates → `✓ dispatch-gates self-test: 633 cases pass.`\n  · check:type-check-coverage → `check-type-check-coverage: OK — 65/78 workspace packages type-checked`\n  · also green: agent-test-spelling, cli-command-ids, cross-package-test-inputs, entry-guard, node-version, parse-guard, pnpm-acquisition, pnpm-filter-targets, required-contexts, shard-attestation, workflow-status-functions, aggregator-roster, ci-filter-parity, check-required-contexts, self-test-workflow-commands, check-shard-attestation, step-collectors, whole-set-label-write, docs-audit/check-drift-comment, pm/ci-failure --self-test, check:nul-bytes.\n\nREPO-WIDE ESLINT RUN IN FULL, NOT NARROWED: `pnpm lint` (= eslint . --no-inline-config) under the shared verify lock → LINT_EXIT=0, `os-verify-lock: VERDICT command-exit 0 · held the lock 73s`.\n\nONE DECLARED NARROWING, with warrant: check:type-check-debt (--re-measure) NOT run. The gate refuses by design without the built dependency closure on disk — `Error: --re-measure cannot run: 56 workspace dependenc(ies) ... have no built type entry point` — which is it correctly refusing to measure a different world (#6376), not a red. Building that closure is a full-farm turbo build; this diff has ZERO TypeScript files and no tsconfig/typecheck script, so no DEBT/TEST_DEBT number can move, and the structural half ran green (47 semantic + 65 observation + 29 re-measure + 28 built-closure + 19 auto-lowering cases). CI builds the closure and runs it regardless.\n\nABLATION — direction PREDICTED IN WRITING BEFORE RUNNING: 'planting mapfile -t _abl < /dev/null at command position in a real tracked script turns the PRODUCTION leg RED with exactly one finding naming that file/line as mapfile/readarray — bash 4.0; --self-test stays GREEN because its verdict does not read tree contents.' Observed exactly that. NO REBUILD LEG APPLIES AND HERE IS WHY: the gate is a plain .mjs run directly by `node scripts/check-bash32-floor.mjs`, with no dist/ and no exports-resolved consumption between the edit and the run — there is no stale-artifact direction for this ablation to fall into, so no `pnpm --filter build` and no ablation-dist-preflight. MUTATION CONFIRMED ON DISK, anchored on the injected text, NOT on the editor's exit code (perl -i exits 0 on zero matches): `== anchor present before mutation: 1 ==`, `== injected-marker count on disk: 1 ==` (guarded: count != 1 aborts with 'ABLATION DID NOT LAND -- readings void'), plus `git diff --stat` showing `scripts/build-console.sh | 1 +`. Readings: PRODUCTION_EXIT=1 with `scripts/build-console.sh:29 / mapfile -t _abl < /dev/null / mapfile / readarray — bash 4.0`; SELFTEST_EXIT=0 with its census line honestly reporting `1 finding(s)`. RESTORE (via `trap '<git checkout HEAD -- $TARGET>' EXIT INT TERM`, so a container SIGTERM mid-mutation could not leave the tree mutated) proven byte-identical: HEAD blob 6be4b62b2b221622aa8a8b1a6da2500633aad58a == on-disk blob 6be4b62b2b221622aa8a8b1a6da2500633aad58a, marker absent (grep count 0), `git status --porcelain: []`.\n\nINSTRUMENT-IS-REAL LEGS inside --self-test (R7a's shape, for R7a's reason — else the leg proves nothing): the BASH_ENV harness is measured BOTH ways on a probe first (`enable -n mapfile readarray` really removes the builtin: plain run prints MAPFILE-WORKS, simulated run does not and stderr names mapfile), then a flagged script really dies at status 127 under it; and an unguarded $EPOCHSECONDS read really is fatal once the variable is gone while the ${EPOCHSECONDS:-} form this gate calls exempt survives the same shell. ⚠️ That EPOCH leg is SOURCED into the shell that unset the variable, never handed to a fresh bash — a child re-creates EPOCHSECONDS on startup, and read from a child the leg would pass by measuring bash 5 twice. First draft did exactly that and read green; caught and fixed.\n\nLABELS: skip-changeset. The additive REST POST was tried first and returned HTTP=403 (`GitHub access is not enabled for this session`) — #12123's defect, not mine — so the declared MCP fallback was used: read (no labels) → union → whole-set write via issue_write, then READ BACK via search_pull_requests, which returns `labels: [{name: 'skip-changeset'}]` on PR 12319, draft: true.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  5. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    ContributorAuthor

    os-dev-report

    ⚠️ Supersedes comment 5414447558, which is the same report but is invisible to a marker scan: the HTML-comment marker on its first line did not survive GitHub's body sanitizer. This copy leads with the literal text instead. Read this one.

    {
      "issue": 12221,
      "status": "done",
      "branch": "claude/issue-12221-bash4-construct-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12319",
      "premise_still_valid": true,
      "summary": "Added scripts/check-bash32-floor.mjs (repo-wide static gate over 15 bash-4+/5 constructs in tracked shell), its --self-test (98 cases, driven end-to-end against a known-bad fixture GIT REPO so the gate is SHOWN to exit 1, not asserted to work), a check:bash32-floor alias, and one lint.yml step. Declared surface only; the two existing file-scoped scans are untouched, and the PR body argues they are NOT redundant — they pair with a simulated-3.2 run that reaches runtime-assembled commands a static scan cannot see, which is exactly this gate's stated blind spot. Both ZONE 3 landing obligations were made UNREACHABLE rather than paid for: population roots are source-literal subtree globs ('scripts/**', '.claude/hooks/**', '.githooks/**') with the walk roots derived from them, so bare-root-worklist records 0 untriaged rows and no escapable-literal row is needed. Neither shrink-only ledger moved. The Q3 answer changed the census: membership is .sh OR shell shebang, which adds .githooks/pre-commit and .githooks/pre-push (18 to 20) — two /bin/sh hooks whose floor is TIGHTER than bash 3.2, not looser. One honest limit, stated in the PR and in the gate's docblock rather than hidden: the GNU doc hosts are egress-blocked from this seat, so the per-row `since` column could not be re-verified upstream. It is documentation only, nothing branches on it, and what IS verified on every run is that each pattern matches a real bash -n-parseable instance of its construct and does not match the 3.2 replacement beside it.",
      "tests": "FINAL COMMIT 185e17abc4. origin/main was merged in FIRST: the initial `dispatch-gates --repo objectstack-ai/objectstack` printed `⚠️ STALE TREE — 3 commit(s) behind ... Stale here: scripts/objectui-changeset-digest.mjs scripts/pm/dispatch-gates.mjs`; after the merge the warning is gone and the union was re-derived. Every exit code captured BEFORE any pipe (the runner redirects to a log, reads $?, then tails).\n\nGATE UNION at 185e17abc4 — all 25 matched families exit 0, plus check:nul-bytes and the two obligation tools. Verdict lines quoted:\n  · check:bash32-floor → `✓ check-bash32-floor self-test: 98 cases pass.` and `✓ check-bash32-floor: 20 tracked shell file(s) under scripts/**, .claude/hooks/**, .githooks/** name no bash 4+ construct outside a comment, a guarded ${VAR:-} read, or a non-command position.`\n  · check-self-test-wired → `✓ check-self-test-wired: every one of the 138 script(s) CI runs that ship a --self-test has that self-test run by CI.`\n  · bare-root-worklist --self-test → `OK  self-test: 46 live row(s), 39 unreachable as spelled, 39 recorded verdict(s) — none stale, none missing.` The live run prints `0 untriaged row(s)` and NO row names this gate.\n  · check:pm-dispatch-gates → `✓ dispatch-gates self-test: 633 cases pass.`\n  · check:type-check-coverage → `check-type-check-coverage: OK — 65/78 workspace packages type-checked`\n  · also green: agent-test-spelling, cli-command-ids, cross-package-test-inputs, entry-guard, node-version, parse-guard, pnpm-acquisition, pnpm-filter-targets, required-contexts, shard-attestation, workflow-status-functions, aggregator-roster, ci-filter-parity, check-cross-package-test-inputs, check-required-contexts, self-test-workflow-commands, check-shard-attestation, step-collectors, whole-set-label-write, docs-audit/check-drift-comment, pm/ci-failure --self-test, check:nul-bytes.\n\nREPO-WIDE ESLINT RUN IN FULL, NOT NARROWED: `pnpm lint` (= eslint . --no-inline-config) under the shared verify lock → LINT_EXIT=0, `os-verify-lock: VERDICT command-exit 0 · held the lock 73s`. No narrowing warrant needed for lint.\n\nONE DECLARED NARROWING, with its warrant: check:type-check-debt (--re-measure) was NOT run. The gate refuses BY DESIGN without the built dependency closure on disk — `Error: --re-measure cannot run: 56 workspace dependenc(ies) ... have no built type entry point` — which is it correctly refusing to measure a different world (#6376), not a red. Building that closure is a full-farm turbo build; this diff has ZERO TypeScript files and no tsconfig or package typecheck script, so no DEBT/TEST_DEBT number can move. The structural half ran green: 47 semantic + 65 observation + 29 re-measure + 28 built-closure + 19 auto-lowering cases. CI builds the closure before that step and runs it regardless.\n\nABLATION — direction PREDICTED IN WRITING BEFORE RUNNING: 'planting mapfile -t _abl reading /dev/null at command position in a real tracked script turns the PRODUCTION leg RED with exactly one finding naming that file and line as mapfile/readarray — bash 4.0; --self-test stays GREEN, because its verdict does not read tree contents.' Observed exactly that, both halves.\n  NO REBUILD LEG APPLIES, AND HERE IS WHY: the gate is a plain .mjs run directly by `node scripts/check-bash32-floor.mjs`, with no dist/ and no exports-resolved consumption between the edit and the run. There is no stale-artifact direction for this ablation to fall into, so no `pnpm --filter build` and no ablation-dist-preflight — the condition those exist for is absent, not skipped.\n  MUTATION CONFIRMED ON DISK, anchored on the injected text, NOT on the editor's exit code (perl -i exits 0 on zero matches): `== anchor present before mutation: 1 ==` and `== injected-marker count on disk: 1 ==`, guarded so a count other than 1 aborts with 'ABLATION DID NOT LAND -- readings void'; plus `git diff --stat` showing `scripts/build-console.sh | 1 +`.\n  READINGS: PRODUCTION_EXIT=1, naming `scripts/build-console.sh:29` / `mapfile -t _abl` / `mapfile / readarray — bash 4.0`. SELFTEST_EXIT=0, with its census line honestly reporting `1 finding(s)`.\n  RESTORE proven byte-identical, driven by a `trap ... EXIT INT TERM` that re-checkouts the file, so a container SIGTERM landing mid-mutation could not leave the tree mutated: HEAD blob 6be4b62b2b221622aa8a8b1a6da2500633aad58a == on-disk blob 6be4b62b2b221622aa8a8b1a6da2500633aad58a; injected marker absent (grep count 0); `git status --porcelain: []`.\n\nINSTRUMENT-IS-REAL LEGS inside --self-test (R7a's shape, for R7a's reason — otherwise the leg passes by proving nothing): the BASH_ENV harness is measured BOTH ways on a probe first (`enable -n mapfile readarray` really does remove the builtin — the plain run prints MAPFILE-WORKS, the simulated run does not and its stderr names mapfile), and then a script this gate flags really does die at status 127 under it. Same for the variables: an unguarded $EPOCHSECONDS read really is fatal once the variable is gone, while the ${EPOCHSECONDS:-} form this gate calls exempt survives the same shell.\n  ⚠️ That EPOCH leg is SOURCED into the shell that unset the variable, never handed to a fresh bash — a child re-creates EPOCHSECONDS on startup, so read from a child the leg would pass by measuring bash 5 twice. The first draft did exactly that and read green; caught by the self-test and fixed.\n\nLABELS: skip-changeset. The additive REST POST was tried FIRST and returned HTTP=403 (`GitHub access is not enabled for this session`) — #12123's defect, not this card's — so the declared MCP fallback was used: read (no labels) → union → whole-set write via issue_write → READ BACK via search_pull_requests, which returns `labels: [{name: 'skip-changeset'}]` on PR 12319 with `draft: true`.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seat

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions