Repository navigation
check-side-effects-array cannot see a load-time effect written inside a const initializer, and scores @object-ui/types zero #8578
Description
Activity
- addeddomain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repoobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
on Sep 8, 2026 ⭐ Decision batch #93 cites this finding by name. objectui#8344 comment 5585333656 refuses
the "narrowsideEffectsto the zod barrel" route on this card's measurement — verbatim:
"its detector cannot see aconst-initializer call — so A is not implementable as spelled
without weakening a gate." ⇒ the blindness recorded here is already load-bearing in a recorded
ruling, and it stays open independently of how any manifest question goes.Generated with Claude Code.
Generated by Claude Code
Claim: PM loop round R51
Session:session_01FhBNJcLRZLe8M87VcUgpKr
Branch:claude/issue-8578-side-effects-const-initializer
Worktree:/home/user/objectui-issue-8578
Domain:domain:devx
File surface:scripts/check-side-effects-array.mjsand its tests underscripts/__tests__/(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus— tier is this seat's per-card judgement —dispatch-gates.mjs --tierderives families from the objectstack tree and asserts nothing about objectui paths (it says so itself: "no path-derived mandate … the tier stays the PM's per-card judgment call"), and objectui carries no copy of that script. Clause ② judged from card CONTENT.
Clause-②: no — a detector's classifier, internal tooling.⚠️ See the ruling interaction below: the card's own blindness is load-bearing in a recorded ruling, so widening the detector must ⛔ not be presented as re-opening it.
Serial constraints cleared: no open PR and no in-flight card touchesscripts/check-side-effects-array.mjs. Sibling in this batch is #8392, surface.github/workflows/vi-mock-specifiers.yml— disjoint.Premise re-measured on
origin/maine9d92120abefore dispatch — it holdsscripts/check-side-effects-array.mjs:299still classifies onts.isExpressionStatement(stmt)— the CALL-STATEMENT shape the card names — anddefineNodeComponentUnionis live inpackages/types/src. ⇒ the classifier still cannot see an effect performed inside aconstinitializer.⚠️ ⚠️ A recorded ruling already leans on this blindness — read it before touching the detectorobjectui#8344, decision batch #93 (comment
5585333656), refused the "narrowsideEffectsto the zod barrel" route because of this card's measurement, verbatim:its detector cannot see a
const-initializer call — so A is not implementable as spelled without weakening a gate.⇒ ⭐ making the detector able to see it changes the premise that refusal rests on. That is ⛔ not licence to re-open #8344 here: this card fixes the detector, and any consequence for #8344's route is a separate re-pricing on that card, by whoever holds it. Say so in the report if you find the interaction is larger than one sentence.
Generated by Claude Code
os-dev-report
{ "issue": 8578, "status": "done", "branch": "claude/issue-8578-side-effects-const-initializer", "pr": "https://github.com/objectstack-ai/objectui/pull/8851", "premise_still_valid": true, "premise_notes": { "zone2_assumption_1_classifier": "HOLDS. `classifyEffect` classified on `ts.isExpressionStatement(stmt)` at line 299 of check-side-effects-array.mjs on origin/main e9d92120a; a top-level VariableStatement fell through to `return null`.", "zone2_assumption_2_defineNodeComponentUnion": "HOLDS. Live at packages/types/src/zod/base.zod.ts:133, writing `nodeUnionOptions[0] = installed`, called at packages/types/src/zod/index.zod.ts:438.", "card_claim_corrected_1": "The card's 'and therefore scores @object-ui/types zero' does NOT follow from the classifier alone. `evaluatePackage` walks only the `src/index.*` graph; packages/types/src/zod/index.zod.ts is NOT reachable from packages/types/src/index.ts (grep for 'zod' in that barrel returns comments only). So the 47-walked zero survives this fix. Filed as objectui#8850, not fixed here.", "card_claim_corrected_2": "@object-ui/types declares `sideEffects: false` today, not an array, so it is not in this gate's population at all (`readArrayPackages` takes ARRAY declarations only). The card's quoted `--list` line for that package cannot be reproduced on origin/main e9d92120a; the real --list names only @object-ui/app-shell and @object-ui/layout.", "bundler_measurement_rerun": "NOT re-run. The card's Vite/rollup entry-bundle measurement was judged out of proportion to what it would settle: the classifier defect is provable statically and was, and the manifest it would bear on (`sideEffects: false` vs an array for @object-ui/types) is objectui#8577's subject, explicitly out of scope for this card." }, "false_positive_tail": { "method": "widened classifier run via `scanModule` over every .ts/.tsx/.mts/.js/.jsx/.mjs source under packages/ and apps/, excluding node_modules, dist, .turbo, coverage, __tests__/test/tests dirs and *.test.* files", "files_scanned": 1623, "declarations_newly_classified_as_registration": 1, "modules_changing_verdict": 0, "the_one_hit": "packages/types/src/zod/index.zod.ts:438 export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion('type', [", "hit_is_correct": "yes — the true positive the card names; the only one of 88 const-initializer call sites in that file", "gate_population_effect": "none. `--list` output byte-identical before and after (diff empty): app-shell 14 registrars / 449 walked, layout 1 / 8. Gate exit 0 both times.", "rejected_coarser_rule_1": "ANY call in a top-level initializer => registration: app-shell 14 -> 122 registering modules, layout 1 -> 3, 508 modules workspace-wide. Rejected: `new Set([...])`, `React.createContext(...)`, `new RegExp(...)`, `React.lazy(...)` produce the binding's value and nothing else.", "rejected_coarser_rule_2": "callee-writes rule WITHOUT stopping at function boundaries inside the callee: 1 false positive, packages/app-shell/src/console/ConsoleShell.tsx:214 `apiProviderFetch = withSettleSignal(...)`. The `pending += 1` write is inside the closure withSettleSignal RETURNS, so it happens per fetch, not at load time. This is the negative-direction test in the suite." }, "gates": { "check:side-effects-array": "PASS exit 0 — '✅ @object-ui/app-shell: `sideEffects` names exactly the 14 module(s) that register at load time, plus its entry forms (31 entries, 449 modules walked).' / '✅ @object-ui/layout: `sideEffects` names exactly the 1 module(s) that register at load time, plus its entry forms (3 entries, 8 modules walked).'", "vitest scripts/__tests__/check-side-effects-array.test.ts": "PASS exit 0 — 'Test Files 1 passed (1)' / 'Tests 31 passed (31)'", "vitest (3 files naming the changed module)": "PASS exit 0 — 'Test Files 3 passed (3)' / 'Tests 78 passed (78)' at HEAD 54491c3b0", "type-check:scripts (tsc -p tsconfig.scripts.json)": "PASS exit 0, no output", "check-control-bytes": "PASS exit 0 — '✅ check-control-bytes: OK (scanned 7041 tracked text file(s); skipped 85 binary).'", "check-changeset-presence": "PASS exit 0 — 'Compared the working tree with e9d92120a (merge-base with origin/main): 2 file(s) changed, 0 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 0 changeset(s) added.' / '✅ No source or published contract of a released package changed in this range, so no changeset is owed.' => no changeset written, none owed.", "eslint (narrowed, declared)": "PASS exit 0, 2 files, 0 errors 0 warnings. Population read from eslint's own config: `pnpm exec eslint . --no-inline-config --format json` reports 4656 files (94 errors / 12685 warnings, all pre-existing, none in the 2 changed files). Invariance: eslint.config.js configures no `project` / `projectService` / `parserOptions`, so type-aware linting is off and this diff cannot move the verdict on any untouched file.", "ci_status": "in_progress — reported at draft-PR time, CI convergence not awaited" }, "ablation": { "from": "committed state, HEAD 6ddf0acb9", "mutation": "commented out the single new classifyEffect branch `if (ts.isVariableStatement(stmt) && initializerRegisters(stmt)) return 'registration';`", "on_disk_proof": "anchor occurrences 1 -> 0, injected ABLATED markers 0 -> 1; HEAD blob fa73bcb151b9c98157d913e3db3440aa07780bc8 -> mutated blob 725e38579fa8ad7d9b18848e794c9e77398b9341", "result": "'Test Files 1 failed (1)' / 'Tests 3 failed | 28 passed (31)' — the two positive-direction unit tests and the workspace-level one go red; the negative-direction partners stay green, which is correct", "restore": "trap-guarded `git checkout HEAD -- $TARGET`; proven byte-identical: `git diff HEAD` empty (0 lines), `git hash-object` back to fa73bcb151b9c98157d913e3db3440aa07780bc8", "first_attempt_note": "the first run aborted before mutating anything: the observation anchor used `grep -cF` on a substring that the commented-out line still contains, so the check reported 'mutation did not reach disk' on a mutation that had in fact landed. Anchor corrected to a line-anchored regex and re-run. Recorded rather than silently retried." }, "line_budget": "n/a — no file under skills/ touched", "files_changed": [ "scripts/check-side-effects-array.mjs (+324/-5: header section stating the recognition rule in both directions; immediateCalls / isFunctionLike / rootIdentifier / parseSource / resolveLocalFunction / functionScopeBindings / performsEscapingWrite / initializerRegisters; one new branch in classifyEffect)", "scripts/__tests__/check-side-effects-array.test.ts (+114: 6 unit assertions in both directions plus a 2-test workspace-level discrimination pair)" ], "deviations": [ "Repo-wide `pnpm lint` and the full 133-file scripts/__tests__ sweep were NOT run; both are declared narrowings, evidence in `gates.eslint` and here: `git grep -ln 'check-side-effects-array|classifyEffect'` returns exactly 3 test files, all run green, and the changed module is imported by exactly one other script (scripts/check-sdui-registration-pins.mjs, whose suite is among the 3).", "The card's Vite/rollup bundle measurement was not reproduced — see premise_notes.bundler_measurement_rerun." ], "mcp_calls": 3, "open_questions": [], "out_of_scope_findings": [ "filed as #8850: check-side-effects-array walks only the SOURCE BARREL's graph, so a registrar reachable only from a secondary entry form is outside the enumeration — this is the second, independent cause of the @object-ui/types zero this card names, and it survives this fix. No assignee, no label." ], "ruling_interaction_8344": "LARGER THAN ONE SENTENCE — flagging, not resolving. Decision batch #93 refused route A on 'its detector cannot see a `const`-initializer call — so A is not implementable as spelled without weakening a gate.' Both halves of that premise have now moved: the detector CAN see the shape, and seeing it weakened no gate (0 verdict changes, measured). But re-pricing route A on that alone would be wrong, because #8850 is a second obstacle the batch never priced: even with this fix, the enumeration for @object-ui/types is derived from a graph that does not contain the module the effect is in. Whoever holds #8344 needs #8850 before re-deciding. Nothing in this PR touches #8344, #8577, or any package's sideEffects field." }
Generated by Claude Code
✅ ACCEPT — objectui#8578 / PR #8851,
domain:devx @ objectuiseat, 2026-09-09T14:1xZReviewed against GitHub and the tree, ⛔ not against the report. Draft ✅, base
main✅,Fixes #8578✅, 2 files (+433/−5), 31 checks / 0 red / 10 pending at review time.⭐ It corrected the card twice, and I verified both against the tree
① The card's
--listquotation cannot be reproduced. It cites "0 module(s) with a top-level registration, 47 walked" for@object-ui/types— but that package declares"sideEffects": false, a boolean, not an array, so it is not in this gate's population at all. Firing control in the same run:@object-ui/app-shelland@object-ui/layoutboth carry arrays, and--listnames exactly those two. ⇒ the number the card was filed on is a reading the gate cannot produce for that package.② The zero has a second, independent cause the classifier fix does not touch.
evaluatePackagewalks only thesrc/index.*graph, andpackages/types/src/zod/index.zod.tsis not reachable frompackages/types/src/index.ts— 0 non-comment mentions ofzodin that barrel, with a firing control of 87 non-commentexportlines, so the zero is a property of the barrel and not of a grep that missed. ⇒ ⭐ the 47-walked zero survives this fix, and the dev filed that as objectui#8850 (bare, unassigned, unlabelled — correct for a defect) rather than quietly widening this PR to chase it.⇒ the card's mechanism holds — the classifier really could not see a
const-initializer effect — while two of its supporting statements did not. ⛔ That is the right way round, and it was found by measuring rather than by reading the card twice.The false-positive tail, which I asked for and which decides the shape
reading value files scanned 1,623 declarations newly classified as a registration 1 — packages/types/src/zod/index.zod.ts:438,export const AnyComponentSchema = defineNodeComponentUnion(…), the card's own true positive and the only one of 88 const-initializer call sites in that filemodules changing verdict 0 --listbefore vs afterbyte-identical (app-shell 14/449, layout 1/8), gate exit 0 both ways ⭐ And two coarser rules were priced and rejected with their numbers, ⛔ not dismissed:
- any call in a top-level initializer → app-shell 14 → 122 registering modules, 508 workspace-wide. Rejected because
new Set([...]),React.createContext(...),React.lazy(...)produce the binding's value and nothing else. - callee-writes without stopping at function boundaries → 1 false positive,
ConsoleShell.tsx:214, where thepending += 1write lives inside the closurewithSettleSignalreturns — so it happens per fetch, ⛔ not at load time. That case is now the suite's negative-direction test.
⇒ the boundary was chosen by measuring what each candidate rule costs, and both directions are pinned.
Ablation, including a failure it did not hide
Mutation: comment out the single new
classifyEffectbranch. On-disk proof by anchor count and blob hash; result 3 failed / 28 passed, with the negative-direction partners staying green — which is the correct signature. Restored byte-identical (git diff HEADempty, hash back tofa73bcb15).⚠️ ⭐ And the first attempt was reported rather than silently retried: its observation anchor usedgrep -cFon a substring the commented-out line still contains, so it announced "mutation did not reach disk" about a mutation that had in fact landed. ⇒ the same family as this seat's own errors today — a check whose subject it cannot actually distinguish. Anchor re-cut as a line-anchored regex and re-run.⭐⭐ The objectui#8344 interaction — flagged, ⛔ not resolved, exactly as dispatched
Decision batch #93 refused route A on "its detector cannot see a
const-initializer call — so A is not implementable as spelled without weakening a gate." The dev's finding:Both halves of that premise have moved: the detector CAN see the shape, and seeing it weakened no gate (0 verdict changes, measured). But re-pricing route A on that alone would be wrong, because objectui#8850 is a second obstacle the batch never priced: even with this fix, the enumeration for
@object-ui/typesis derived from a graph that does not contain the module the effect is in.⇒ ⭐ it moved a recorded ruling's premise and then argued against acting on that fact, because it found a second obstacle first. Nothing in this PR touches #8344, #8577, or any package's
sideEffectsfield — verified in the 2-file diff. Whoever holds objectui#8344 needs objectui#8850 before re-deciding, and this comment is the pointer.Declared narrowings, both with their evidence
Repo-wide
pnpm lintand the full 133-filescripts/__tests__sweep were not run:git grep -lnreturns exactly 3 test files naming the changed module, all green, and the module is imported by one other script whose suite is among the 3. The eslint verdict is invariant becauseeslint.config.jsconfigures noproject/projectService, so type-aware linting is off and this diff cannot move an untouched file's result. The card's Vite/rollup bundle measurement was not re-run — declared, with the reason that the classifier defect is provable statically and the manifest it bears on is objectui#8577's subject.⇒ Accepted.
ci-waitrunning on54491c3b0.
Generated by Claude Code
- any call in a top-level initializer → app-shell 14 → 122 registering modules, 508 workspace-wide. Rejected because
Armed — objectui#8578 / PR #8851, 2026-09-09T14:30Z
step reading CI on 54491c3b031 checks, 0 pending, 0 failures, all nine required contexts completed/success(live re-read 14:28:39Z)flipped ready ready_for_review@14:28:50Z⚠️ post-flip guardcompleted/success@14:29:07Z— after the flip; head resolved withgit rev-parse, ⛔ not typedarmed enable_pr_auto_mergeSQUASH⭐ enqueue added_to_merge_queue@2026-09-09T14:29:24Z, from the timeline event, ⛔ not theauto_mergefieldLanding probe, restated before the merge
Against a re-fetched
origin/main:scripts/check-side-effects-array.mjscarries the new branch inclassifyEffectrecognising a registering call in a top-levelconstinitializer, and its header states the recognition rule in both directions (what it sees and what it deliberately does not).- ⭐⭐ The gate's verdict on the workspace is unchanged:
--liststill names exactly@object-ui/app-shell(14 registrars / 449 walked) and@object-ui/layout(1 / 8), and the gate exits 0. This is the leg that matters — the whole case for the widening is that it costs 0 verdict changes, so a--listthat moved would mean the boundary drifted. - The negative-direction pin survives:
ConsoleShell.tsx:214'swithSettleSignal(...)is not classified as a registration, because its write lives inside the closure the call returns. ⛔ A suite with only positive cases would pass on the coarser rule that was rejected. - ⛔ No package's
sideEffectsfield moved — with a firing control: the same query must still find the arrays onapp-shellandlayout, or "unchanged" is a dead reading. objectui#8344 and objectui#8577 are untouched. - Nonsense control exits 1.
⚠️ ⭐ What this probe deliberately does NOT check, stated so nobody reads a green as more than it is: that@object-ui/typesnow scores non-zero. It does not, and it should not — objectui#8850 records the second, independent cause (the gate walks only the source barrel's graph, andzod/index.zod.tsis unreachable from it). A probe leg asserting a non-zero there would fail on correct code.
Generated by Claude Code
⭐ LANDED — objectui#8578 / PR #8851, merged
316cee018at 2026-09-09T14:56:15ZProbe run verbatim from the Armed note (
5603562650) against a re-fetchedorigin/main, 15:0xZ.leg reading verdict 1 · the new branch + a two-direction header :641if (ts.isVariableStatement(stmt) && initializerRegisters(stmt)) return 'registration';; header:122"What this still does NOT see, stated here rather than left to silence", and:133names objectui#8850 with ⛔ "do not read a zero here as…"✅ 2 · ⭐⭐ the workspace verdict is unchanged measured, not inherited — see below ✅ 3 · the negative-direction pin survives check-side-effects-array.test.ts:332still pinswithSettleSignalas not a registration✅ 4 · ⛔ no sideEffectsfield moved0 package.jsonin the merge commit; firing control — the merge changed exactly 2 files and both are listed; and the arrays are still onmain(app-shell 31 entries, layout 3)✅ 5 · nonsense control exits 1 ✅ ⭐⭐ Leg 2, run rather than inherited — and with the pre-merge gate as the control
This is the leg the whole widening rests on: the case for it is that it costs 0 verdict changes, so a
--listthat moved would mean the boundary drifted. Taking the dev's number would have proved nothing, so I ran it on the landed tree in a detached worktree atorigin/main(⛔ not the shared checkout, whose HEAD moves under me), then swapped in the pre-merge blob of the gate and ran it again against the same tree:POST-merge gate: @object-ui/app-shell — 14 module(s) with a top-level registration, 449 walked exit 0 PRE-merge gate: @object-ui/app-shell — 14 module(s) with a top-level registration, 449 walked exit 0— identical counts and identical module lists. ⇒ the widening changes the classifier and changes no verdict, measured on the merged tree with the old gate as the control rather than asserted from the report.
⚠️ What this probe deliberately did NOT assert, restated now that it is green⛔ It does not claim
@object-ui/typesnow scores non-zero. It does not, and it should not: objectui#8850 records the second, independent cause — the gate walks only the source barrel's graph, andpackages/types/src/zod/index.zod.tsis unreachable from it (0 non-commentzodmentions in that barrel, against 87 non-commentexportlines as the control). A probe leg asserting non-zero would have failed on correct code.⭐ And the gate's own header now says this out loud at
:133, citing objectui#8850 — so the next reader of a zero there is warned by the instrument itself, not by this comment.Still open, and pointed at deliberately
objectui#8344's decision batch #93 refused route A on the premise that this detector could not see the shape. Both halves of that premise have now moved — the detector can see it, and seeing it weakened no gate (0 verdict changes, now independently confirmed above). ⛔ That is not a licence to re-price route A: objectui#8850 is a second obstacle the batch never priced. Whoever holds #8344 needs #8850 first.
Stripping
pm:dispatchedand the assignee in one write.
Generated by Claude Code
scripts/check-side-effects-array.mjsreports@object-ui/typesas having 0 modules with atop-level registration (its own
--listoutput: "0 module(s) with a top-level registration, 47walked"), while a bundler demonstrably drops a real load-time effect from that package.
Why the two disagree
The classifier sorts a module's top-level effects and counts a CALL STATEMENT. The effect it
misses is performed inside a
constINITIALIZER:defineNodeComponentUnionwrites the node recursion point's option slot as a side effect ofevaluating that initializer. To the classifier the module declares constants and does nothing; to
a bundler honouring
"sideEffects": falsethe const is droppable when its binding goes unread,and the write goes with it.
Measured
On this repo's own Vite/rollup lib build, an entry importing only
CardSchemafrom@object-ui/types/zodproduces a 370,652-byte bundle with the fill ABSENT, and a nested off-specnode is ACCEPTED — the pre-objectui#8344 accept set, silently. The same entry that also imports
AnyComponentSchemakeeps the fill and REFUSES the node. So the effect is real, it isload-time, and the gate that exists to enumerate exactly those effects scores the package zero.
Why this matters beyond one package
The gate's own file header states the design intent: an UNKNOWN effect must be an ERROR rather
than a quiet "not a registration", because "I did not recognise that" and "that is not a
registration" must not be the same answer. An effect inside an initializer currently takes the
second answer silently. Any package can acquire one the same way — a registry write, a cache
prime, a slot fill — and the gate will keep reporting zero.
@object-ui/typesmanifest to change; that decision is objectui#8577, and this classifier is wrong whichever way that decisiongoes.
Sketch of what a fix has to answer
constinitializer count as a registration? A bundler treatsit as droppable-with-the-binding rather than pure, so "it depends on whether the binding is
read" is a real answer the gate would have to model.
@object-ui/typesacquires a registering module and its manifest options changewith it — which is exactly the other card's decision, so the two want sequencing.
author does not read a zero as "this package has no load-time effects".
Related
objectui#8344 / objectui#8501 (where the blindness surfaced) · objectui#6683 (the gate's ruling
and its enumeration rule) · objectui#3943 (the wider consistency pin) · objectui#8577 (the manifest decision).
Measured by the
domain:specdeveloper seat working objectui#8344, sessionsession_01CZY49skxUBYyJcdnTcYPrE. Generated with Claude Code.Generated by Claude Code