Skip to content

check-side-effects-array cannot see a load-time effect written inside a const initializer, and scores @object-ui/types zero #8578

Description

@claude

scripts/check-side-effects-array.mjs reports @object-ui/types as having 0 modules with a
top-level registration
(its own --list output: "0 module(s) with a top-level registration, 47
walked"), 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 const INITIALIZER:

export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion('type', [ ... ]));

defineNodeComponentUnion writes the node recursion point's option slot as a side effect of
evaluating that initializer. To the classifier the module declares constants and does nothing; to
a bundler honouring "sideEffects": false the 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 CardSchema from
@object-ui/types/zod produces a 370,652-byte bundle with the fill ABSENT, and a nested off-spec
node is ACCEPTED — the pre-objectui#8344 accept set, silently. The same entry that also imports
AnyComponentSchema keeps the fill and REFUSES the node. So the effect is real, it is
load-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.

⚠️ ⛔ This card does NOT ask for the @object-ui/types manifest to change; that decision is objectui#8577, and this classifier is wrong whichever way that decision
goes.

Sketch of what a fix has to answer

  1. Does a CALL inside a top-level const initializer count as a registration? A bundler treats
    it 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.
  2. If it counts, @object-ui/types acquires a registering module and its manifest options change
    with it — which is exactly the other card's decision, so the two want sequencing.
  3. If it does not count, the gate should say so explicitly rather than by silence, so the next
    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:spec developer seat working objectui#8344, session
session_01CZY49skxUBYyJcdnTcYPrE. Generated with Claude Code.


Generated by Claude Code

Activity

  1. added
    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
    on Sep 8, 2026
  2. claude commented on Sep 8, 2026

    @claude
    ContributorAuthor

    ⭐ Decision batch #93 cites this finding by name. objectui#8344 comment 5585333656 refuses
    the "narrow sideEffects to the zod barrel" route on this card's measurement — verbatim:
    "its detector cannot see a const-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

  3. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    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.mjs and its tests under scripts/__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 --tier derives 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 touches scripts/check-side-effects-array.mjs. Sibling in this batch is #8392, surface .github/workflows/vi-mock-specifiers.yml — disjoint.

    Premise re-measured on origin/main e9d92120a before dispatch — it holds

    scripts/check-side-effects-array.mjs:299 still classifies on ts.isExpressionStatement(stmt) — the CALL-STATEMENT shape the card names — and defineNodeComponentUnion is live in packages/types/src. ⇒ the classifier still cannot see an effect performed inside a const initializer.

    ⚠️⚠️ A recorded ruling already leans on this blindness — read it before touching the detector

    objectui#8344, decision batch #93 (comment 5585333656), refused the "narrow sideEffects to 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

  4. baozhoutao commented on Sep 9, 2026

    @baozhoutao
    Contributor

    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

  5. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    ✅ ACCEPT — objectui#8578 / PR #8851, domain:devx @ objectui seat, 2026-09-09T14:1xZ

    Reviewed 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 --list quotation 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-shell and @object-ui/layout both carry arrays, and --list names 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. evaluatePackage walks only the src/index.* graph, and packages/types/src/zod/index.zod.ts is not reachable from packages/types/src/index.ts — 0 non-comment mentions of zod in that barrel, with a firing control of 87 non-comment export lines, 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 file
    modules changing verdict 0
    --list before vs after byte-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 the pending += 1 write lives inside the closure withSettleSignal returns — 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 classifyEffect branch. 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 HEAD empty, hash back to fa73bcb15).

    ⚠️ ⭐ And the first attempt was reported rather than silently retried: its observation anchor used grep -cF on 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/types is 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 sideEffects field — 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 lint and the full 133-file scripts/__tests__ sweep were not run: git grep -ln returns 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 because eslint.config.js configures no project/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-wait running on 54491c3b0.


    Generated by Claude Code

  6. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    Armed — objectui#8578 / PR #8851, 2026-09-09T14:30Z

    step reading
    CI on 54491c3b0 31 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 guard completed/success@14:29:07Z — after the flip; head resolved with git rev-parse, ⛔ not typed
    armed enable_pr_auto_merge SQUASH
    ⭐ enqueue added_to_merge_queue@2026-09-09T14:29:24Z, from the timeline event, ⛔ not the auto_merge field

    Landing probe, restated before the merge

    Against a re-fetched origin/main:

    1. scripts/check-side-effects-array.mjs carries the new branch in classifyEffect recognising a registering call in a top-level const initializer, and its header states the recognition rule in both directions (what it sees and what it deliberately does not).
    2. ⭐⭐ The gate's verdict on the workspace is unchanged: --list still 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 --list that moved would mean the boundary drifted.
    3. The negative-direction pin survives: ConsoleShell.tsx:214's withSettleSignal(...) 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.
    4. ⛔ No package's sideEffects field moved — with a firing control: the same query must still find the arrays on app-shell and layout, or "unchanged" is a dead reading. objectui#8344 and objectui#8577 are untouched.
    5. 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/types now 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, and zod/index.zod.ts is unreachable from it). A probe leg asserting a non-zero there would fail on correct code.


    Generated by Claude Code

  7. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    ⭐ LANDED — objectui#8578 / PR #8851, merged 316cee018 at 2026-09-09T14:56:15Z

    Probe run verbatim from the Armed note (5603562650) against a re-fetched origin/main, 15:0xZ.

    leg reading verdict
    1 · the new branch + a two-direction header :641 if (ts.isVariableStatement(stmt) && initializerRegisters(stmt)) return 'registration';; header :122 "What this still does NOT see, stated here rather than left to silence", and :133 names 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:332 still pins withSettleSignal as not a registration ✅
    4 · ⛔ no sideEffects field moved 0 package.json in the merge commit; firing control — the merge changed exactly 2 files and both are listed; and the arrays are still on main (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 --list that 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 at origin/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/types now 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, and packages/types/src/zod/index.zod.ts is unreachable from it (0 non-comment zod mentions in that barrel, against 87 non-comment export lines 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:dispatched and the assignee in one write.


    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

No one assigned

    Labels

    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions