Skip to content

ineffective-dynamic-import-ledger's closeBundle counter-probe replaces the real error whenever an earlier console plugin fails the build #6093

Closed

Description

@yinlianghui-tw

Found while reproducing #5996 (the eager-closure ENOENT). Not caused by that card and not fixed there — filing so triage can rank it.

What I measured

scripts/vite-ineffective-dynamic-imports.ts ends with a counter-probe in closeBundle: if none of the 43 pinned INEFFECTIVE_DYNAMIC_IMPORT warnings fired, it errors. The probe is well-motivated — its own message says so: "or this build never got far enough to emit them, in which case the number to distrust is the zero, not the list."

The problem is what happens to the other error. closeBundle runs even when the build has already failed, and the error it throws supersedes the aggregated build error in vite build's output. The real cause is not appended, not printed above, not present anywhere in the log — it is gone.

Measured on apps/console, vite@8.2.1 + rolldown@1.2.3, base 7c96c9420, by making emit-eager-closure-report's writeBundle fail (an out-of-tree OBJECTSTACK_CLIENT_DIST override whose own dependencies do not resolve — see the sibling card).

With the full plugin list — the only thing the developer sees:

error during build:
Error [RolldownError]: 43 pinned ineffective dynamic import(s) did NOT fire. Either they were fixed
— delete them from `DEFEATED_LAZY_FIELD_WIDGETS` in `scripts/vite-ineffective-dynamic-imports.ts`
and record the win in the PR — or this build never got far enough to emit them, in which case the
number to distrust is the zero, not the list.
  - packages/fields/src/widgets/AddressField.tsx
  … 42 more lines …

grep -c ENOENT on that log: 0. The actual failure is invisible.

Same build, same override, that one plugin removed from the array:

error during build:
Build failed with 1 error:

[plugin emit-eager-closure-report]
Error: ENOENT: no such file or directory, open '.../apps/console/dist/@objectstack/core/logger'

Why it is worth a line

This is generic, not specific to the plugin I happened to be debugging: any console build that dies before the pinned warnings are emitted reports the ledger's counter-probe instead of its own cause, and hands the developer a 45-line list of field widgets that have nothing to do with the failure. The 43 did NOT fire message reads as a ledger drift, so the first instinct is to go edit DEFEATED_LAZY_FIELD_WIDGETS — which is exactly the wrong move and would break the ledger for real.

This also blunts work that lands elsewhere: PR for #5996 replaces that ENOENT with a self-diagnosing message naming the unresolved import, and under the real plugin stack that message is masked too — zero occurrences in the log, before and after. The improvement is correct but invisible until this is handled.

Possible shapes (not a recommendation)

  • Have the counter-probe stand down when the build has already errored — the condition its own message already describes ("this build never got far enough"). Detecting that from closeBundle needs a flag set by a buildEnd/renderError hook, since the probe cannot otherwise tell "zero because fixed" from "zero because dead".
  • Or keep it unconditional but make it non-masking: emit as a warning when a prior error is known, so the real error still reaches the top.
  • Either way the pinned-drift signal the probe exists for must survive on a successful build, which is the case it was written for.

Related: #5325 (the ledger), #5996 (where this was found).

Activity

  1. added
    bugSomething isn't working
    domain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
    on Aug 24, 2026
  2. yinlianghui-tw commented on Aug 24, 2026

    @yinlianghui-tw
    CollaboratorAuthor

    PM claim + dispatch order — R15, with the shape ruled

    Claimed by the domain:devx @ objectui execution seat (#5748), round R15, PM session session_019b5UBNMtTzKbVtZZGvFuxe. Branch: claude/issue-6093-counter-probe-masking.

    This is the highest-value card of the round. Not because the fix is large, but because of what it currently costs: every console build that dies before the pinned warnings are emitted reports the ledger's counter-probe instead of its own cause. The developer gets a 45-line list of field widgets unrelated to the failure, and the message reads as ledger drift — so the first instinct is to go edit DEFEATED_LAZY_FIELD_WIDGETS, which is exactly the wrong move and would break the ledger for real. A wrong signal that actively misdirects is worse than no signal.

    Unblocked as of now: PR #6095 merged at 17:20, so apps/console/vite.config.ts is free.

    Ruling: shape 1 — the probe stands down when the build has already errored

    Take the first option. It is the condition the probe's own message already names: "or this build never got far enough to emit them, in which case the number to distrust is the zero, not the list." The probe already knows this case exists; it just cannot currently detect it. Give it the means.

    As the card says, that needs a flag set from a buildEnd / renderError hook, because closeBundle cannot otherwise distinguish "zero because fixed" from "zero because dead". Those two readings are the whole point of the probe, so the flag is not a workaround — it is the missing input.

    ⛔ Shape 2 (downgrade to a warning) is rejected as the primary fix: a warning on a failed build still adds 45 lines of noise above the real error, and on a successful build a warning would silently weaken the drift signal the probe exists for. Take it only as a fallback if shape 1 proves impossible in this plugin's hook ordering — and if you do, say so and explain why.

    ⛔ Do not delete the probe, do not touch DEFEATED_LAZY_FIELD_WIDGETS, and do not change what the ledger pins.

    ⚠️ The constraint that decides whether this fix is right

    The pinned-drift signal must still fire on a successful build where the warnings genuinely did not fire. That is the case the probe was written for, and a fix that suppresses it there would trade a masking bug for a blind gate — strictly worse, and invisible.

    So the deliverable is three measured states, not one:

    build expected
    fails before warnings emit the real error reaches the top; the probe is silent
    succeeds, warnings fired probe silent, build green
    succeeds, warnings did not fire probe errors, exactly as today

    The third row is the control. Without it you have not shown the probe still works — only that you turned it off.

    Verification

    • Reproduce the masking first, then show it gone, on the same reproduction. The card gives you one: an out-of-tree OBJECTSTACK_CLIENT_DIST override whose own dependencies do not resolve. Under the unmodified plugin list, grep -c ENOENT on the log is 0 — that is the before. The after must show the real error present, and grep -c is the way to say so.
    • ⚠️ PR fix(console): name the unresolved import when the eager-closure walk hits a non-chunk #6095 landed a self-diagnosing message for that exact failure, replacing the bare ENOENT. So your "after" should surface that message, not the old one — rebase on current main and quote what actually appears. Its author noted the improvement is invisible until this card lands; your PR is what makes it visible, and demonstrating that is the strongest evidence this fix works.
    • ⚠️ A build artifact sits between your edit and the thing under test — vite compiles the config through configLoader: 'native' into a per-invocation .vite-temp file. emit-eager-closure-report crashes with ENOENT when a fully-unresolvable dependency lands in the vendor-objectstack chunk #5996's dev used the differing timestamped filenames in the two stack traces to prove neither run read a cached compile; do the same or better, and state it.
    • Run the suites that reach this plugin, from the repo root.

    ⛔ Out of scope: the eager-closure report (#5996, landed), OBJECTSTACK_CLIENT_DIST validation (#6094, dispatched in parallel — ⛔ do not touch it, another dev is on it), and the wider question of whether unresolved bare imports should fail the console build generally.

    ⛔ Never git stash — shared stack, other agents are using it now.

    ⚠️ Lead your report comment with the literal text os-dev-report on the first line, and write any angle-bracket fragment in words — GitHub's sanitizer strips both HTML comments and short angle-bracket fragments, even inside fenced code blocks.

    Refs: #5325 (the ledger) · #5996 / PR #6095 (where this was found) · #6094 (sibling, parallel).


    Generated by Claude Code

  3. yinlianghui-tw commented on Aug 24, 2026

    @yinlianghui-tw
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 6093,
      "status": "done",
      "branch": "claude/issue-6093-counter-probe-masking",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6113",
      "premise_still_valid": true,
      "summary": "Took shape 1 as ruled: the closeBundle counter-probe in scripts/vite-ineffective-dynamic-imports.ts now stands down when the build has already failed, so the build's own error reaches the top instead of being replaced by 45 lines of field widgets. The ledger, DEFEATED_LAZY_FIELD_WIDGETS and the probe's failure message are untouched. ONE DEVIATION FROM THE RULING'S MECHANISM, and it is the load-bearing finding: a buildEnd/renderError flag alone does NOT fix this card's own reproduction. Measured on vite 8.2.1 + rolldown 1.2.3 via a harness driving real vite.build() runs — buildEnd is handed the error ONLY for build-phase (transform) failures; renderError fires ONLY for renderChunk failures; for generateBundle and writeBundle failures neither hook is told anything, and the card's repro is a writeBundle error from emit-eager-closure-report. So a third input was added: the plugin marks its own writeBundle, declared order: 'post' so the marker is not armed by a plugin ordered after it (measured both ways). 'closeBundle reached, writeBundle never ran' is 'this build did not finish'. build.write === false is the one build that legitimately reaches closeBundle with no writeBundle, so the marker is read as failure evidence only when the build was going to write — that direction keeps the probe ARMED rather than silently disarming it. A stand-down prints exactly one line saying the ledger was not checked and why; zero output would be indistinguishable from a gate that passed.",
      "tests": "THREE MEASURED STATES, real apps/console vite build, base a100f77d3, fix 1ccafd03d. Repro per the card: out-of-tree OBJECTSTACK_CLIENT_DIST override whose dependency @objectstack/core/logger is confirmed MODULE_NOT_FOUND. STATE 1 (build fails before warnings emit): before, unmodified plugin — grep -c 'UNRESOLVED BARE IMPORT' = 0, grep -c 'did NOT fire' = 1, 67 log lines; after — grep -c 'UNRESOLVED BARE IMPORT' = 1, grep -c 'did NOT fire' = 0, 44 log lines. grep -c ENOENT is 0 both sides, correctly: PR #6095 replaced the bare ENOENT, and #6095's message is what now reaches the top ('error during build: / Build failed with 1 error: / [plugin emit-eager-closure-report] / RolldownError: ... eager-closure member `@objectstack/core/logger` has no file in ... UNRESOLVED BARE IMPORT ... (imported by: assets/framework-CXWLXU9l.js)'). STATE 2 (succeeds, warnings fired): clean vite build BUILD_EXIT=0, probe silent, summary line unchanged ('43 ineffective dynamic imports, all pinned (objectui#5325): 27 via FieldEditWidget.tsx ...'), eager-closure byte-identical to the pre-change baseline (3298316 bytes gzipped, 52/508 chunks) — this also proves the order:'post' writeBundle actually runs under the console's full plugin array. STATE 3, THE CONTROL (succeeds, warnings did NOT fire): same override with the unresolved import left tree-shakeable, build completes (eager-closure report written, 52/508 chunks, 3284846 bytes) yet none of the 43 fire — probe STILL errors, full 43-line 'did NOT fire' message, exactly as today. CONFIG FRESHNESS: configLoader 'native' compiles the config into a per-invocation apps/console/node_modules/.vite-temp/vite.config.ts.timestamp-*.mjs; four builds named four distinct temp files in their stack traces (1787592587612-53c9ef75413af, 1787592900399-0b15cbc6a0c3a, 1787593428472-e3df3c03aa7f9, 1787593608193-d78954b1d01f9), and .vite-temp/ was verified EMPTY before and after each build — vite deletes the compile after importing it, so there is no cache a stale compile could come from. ABLATION / REVERSE VERIFICATION: predicted direction was 'the three stand-down tests go red, the controls stay green'. Ablated buildDidNotFinish() to a constant false; mutation confirmed ON DISK by grep counts (original string 1 then 0, ABLATED marker 0 then 1) rather than by any editor exit code; restored by a trap on EXIT INT TERM and the tree verified byte-clean afterwards (git status empty, ABLATED absent, original line present). Result: 3 failed / 21 passed — exactly STATE 1, 1b, 1c red; STATE 3, 3b and the write:false test green. No dist rebuild is involved: the suite imports the plugin from source (../vite-ineffective-dynamic-imports.ts), no package exports indirection. GATES, all on final HEAD 1ccafd03d, run from the repo ROOT: 'pnpm exec vitest run scripts/__tests__ --maxWorkers=2' — 'Test Files 67 passed (67) / Tests 1857 passed (1857)'; 'pnpm run type-check:scripts' (tsc -p tsconfig.scripts.json) exit 0; eslint --no-inline-config on the two changed files — 0 errors 0 warnings (from --format json); 'pnpm run check:control-bytes' — 'check-control-bytes: OK (scanned 5057 tracked text file(s); skipped 85 binary)'; 'node scripts/check-changeset-presence.mjs' — 'No source of a released package changed in this range, so no changeset is owed' (diff is scripts/ only, so no changeset and no empty-frontmatter declaration is owed either; objectui has no skip-changeset label and none was created). DECLARED NARROWING: repo-wide 'pnpm lint' was narrowed to the two changed files. Total read from eslint's own --format json (2 files); eslint here is not type-aware (no projectService and no parserOptions.project in the flat config), so this diff cannot move the verdict on any untouched file; the full farm runs in CI regardless. Exit codes were captured before any pipe throughout, and every verdict quoted above is the gate's own printed line. All heavy runs went through the shared container lock (scripts/pm/os-verify-lock.sh), verdicts: five ACQUIRED runs, longest hold 75s, longest wait 55s.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    One thing the PM should weigh, recorded here rather than as a new card — the ruling specified the flag as buildEnd / renderError. That pair is measurably insufficient for this card's own reproduction (a writeBundle failure tells neither hook anything), so the fix adds a third input: the plugin's own writeBundle at order: 'post'. It is still shape 1 — a flag set from hooks that are told what closeBundle is not — but it is not the two hooks named. The hook-by-phase table is in the PR body and in the source header. Shape 2 was not needed and was not taken.


    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

Labels

bugSomething isn't workingdomain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repopm:dispatched

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions