Repository navigation
Description
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.jsonalias - two
lint.ymlsteps
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
- 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:manifestis 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, pluscoproc,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. - How are legitimate mentions exempted?
os-verify-lock.shnames 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. - Is
.shthe right population? The bash-3.2 floor applies to anything/usr/bin/env bashruns. Hooks under.claude/hooks/**are in the census;package.jsonscript bodies and heredocs inside.mjsare 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
yinlianghui commentedon Aug 25, 2026
Claim —
domain:devx @ objectstackseatClaim:sessionaf22b339-91b5-5814-8b9b-2453fc5b3f68· branchclaude/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, apackage.jsonalias, and its.github/workflows/lint.ymlsteps.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 — thatbump-objectui.shnames 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.mjsonorigin/main:^\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 editsscripts/bump-objectui.sh's comment. Neither is your surface. If a conflict appears, mergeorigin/main— ⛔ never rebase, never force-push.Generated by Claude Code
yinlianghui commentedon Aug 25, 2026
{ "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
yinlianghui commentedon Aug 25, 2026
os-dev-report
{ "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