Skip to content

fix(console): feed the lazy-linter counter-probe the injection-aware spec test (#5388) - #5390

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-5388-lazy-linter-injection
Aug 20, 2026
Merged

fix(console): feed the lazy-linter counter-probe the injection-aware spec test (#5388)#5390
os-project-manager merged 1 commit into
mainfrom
claude/issue-5388-lazy-linter-injection

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#5388

assertLazyLinterStaysLazy() carried its own private @objectstack/spec module-id regex
while the vendor-objectstack chunk group three lines away already read the widened one
resolveSpecDistInjection publishes. Under OBJECTSTACK_SPEC_DIST all 18 spec specifiers
resolve to absolute paths in the overriding tree — ids with no @objectstack segment at
all — so the private regex matched zero modules, the counter-probe correctly refused a
verdict, and it took every injected console build with it.

The counter-probe was never wrong about what it saw. The defect is one consumer of
resolveSpecDistInjection not reading its producer's output.

The seam, re-verified on origin/main (0fd11444a)

Every line number in the issue is still accurate, checked before editing:

linewhat it does
321–322resolveSpecDistInjection(process.env.OBJECTSTACK_SPEC_DIST, { vendorChunkTest: VENDOR_OBJECTSTACK_TEST })
345–347const vendorObjectstackTest = specDistInjection ? specDistInjection.vendorChunkTest : VENDOR_OBJECTSTACK_TEST
442chunk grouping reads vendorObjectstackTest
76–78the plugin declares its own private LINT and SPEC
366the plugin is registered with no argument

The fix — one producer, both consumers

  • resolveSpecDistInjection now publishes specModuleTest beside vendorChunkTest,
    widened by a single shared widen() rule so the two tests cannot drift apart again.
  • assertLazyLinterStaysLazy(specTest) takes the spec test as a parameter; the config
    passes the injection-aware specModuleTest, exactly as line 442 does for the group test.
  • specModuleTest is deliberately notvendorChunkTest. That one is the whole vendor
    scope minus the linter, so an eager @objectstack/client would satisfy it without
    proving the walk can see the spec chunk. Reusing it would have kept the build green by
    lowering the bar — the one outcome this issue's acceptance rules out. There is a test
    pinning that distinction.

Nothing about what resolveSpecDistInjection rewrites changed, and the counter-probe is
not weakened anywhere.

The LINT half — decided, not deferred

LINT does not become injection-aware in this PR, and it gains a counter-probe instead.

Nothing injects @objectstack/lint today: resolveSpecDistInjection is spec-only by name
and by SPEC_PACKAGE_NAME, so there is no producer publishing a lint location to read.
"Injection-aware LINT" today could only mean inventing that producer — speculative
surface with no consumer, and untestable, because there would be no injection to test it
against. objectstack#9659 is a proposal, not a landed mechanism.

But the risk the issue names is real, and it is worse on this half than on the spec half.
The spec test going blind fails loud — the build stops, which is exactly what happened
here. The lint test going blind fails silent: the assertion on it is negative, so zero
matching modules is indistinguishable from a bundle that keeps the linter properly lazy,
and the guard goes green forever while ~89 KiB gzipped ships on every page load. That is
#5323's own reasoning ("a green check with no subject"), applied to its own regex rather
than to its graph walk.

So the linter half now gets the same refusal to guess, using only facts that are true
today: the linter is always in this bundle somewhere (app-shell's capabilityLint.ts
await imports it), so zero matches anywhere — eager or lazy — is a statement about the
regex, never about the graph
, and it fails the build with a message that names both ways
to get there. If lint later joins the injection set, this says so instead of quietly
ceasing to guard anything.

Evidence — builds, all four combinations, at b7b2b3b8c

The failing baseline was established first, on unfixed origin/main. The override
points at a real @objectstack/spec package placed outside node_modules, which is the
only property of the framework tree that matters here — /…/objectstack/packages/spec and
CI's /home/runner/work/objectstack/objectstack/packages/spec both have no @objectstack
segment either.

sourcesOBJECTSTACK_SPEC_DISTresult
unfixed 0fd11444asetEXIT=1counter-probe failed: no eagerly loaded chunk contains an @objectstack/spec module
unfixed 0fd11444aunsetEXIT=0
fixed b7b2b3b8csetEXIT=0, 8592 modules transformed
fixed b7b2b3b8cunsetEXIT=0, 8592 modules transformed

The probe finds the injected spec — it is not skipped. Read off the injected build's
own dist/stats.html: 13 distinct module ids under the override's directory are in the
bundle and 0 ids from the installed @objectstack+spec@17.0.0, i.e. the build really
was running on the injected spec when the counter-probe returned its verdict. The
vendor-objectstack chunk it landed in is 5,055.99 kB / 1,539.84 kB gzip, byte-identical
in both modes.

Reverse verification — the guard can still fail

With the fix in place, the negative lookaheads were removed from VENDOR_OBJECTSTACK_TEST
so the vendor group claims the linter. Predicted before running: plain RED, and on the
linter message rather than the counter-probe, because reaching that verdict at all is
what the fix restores. Measured, OBJECTSTACK_SPEC_DISTset:

error during build:
Build failed with 1 error:
[plugin assert-lazy-linter-stays-lazy]
RolldownError: [assert-lazy-linter-stays-lazy] `@objectstack/lint` is in the EAGER closure
(assets/vendor-objectstack-BXk7O0mY.js), so every console page load pays for it — ~89 KiB
gzipped, measured in objectui#5266.

Same RED with the override unset, same message. The ablation was applied on top of the
committed fix and reverted with git checkout HEAD --; the tree is clean.

Tests

scripts/__tests__/vite-objectstack-spec-dist.test.ts gains a block that drives the
real plugin off the real console config over a synthetic bundle — pinning the
wiring rather than a regex value, since the bug was a correct regex reaching one consumer
and not the other:

  • the un-injected config is blind to an injected spec id (the control),
  • the injected config finds it,
  • …and still refuses a verdict on a bundle with no eager spec, and on one whose only eager
    @objectstack module is client — so the widening did not defang it,
  • the eager-linter verdict still fires under the override, the mode that previously
    never reached it,
  • and the new LINT counter-probe fires when the linter matches nothing at all.

One thing worth recording, because the first version of this test passed for the wrong
reason: installedSpecDir cannot stand in for an injected package. Its own path is
…/node_modules/.pnpm/@objectstack+spec@17…/node_modules/@objectstack/spec, which the
baseline regex already matches — measured green under the un-injected config before the
fixture was replaced with a real package at a path of the right shape. The fixture now
asserts that property about itself.

vitest run --project unit scripts/__tests__/{vite-objectstack-spec-dist,vitest-config-alias-targets-3944,side-effects-declaration-consistency,check-type-check-coverage}.test.ts
Test Files 4 passed (4) Tests 381 passed (381)

Also green at b7b2b3b8c: type-check:scripts; tsc -b apps/console/tsconfig.node.json --force (the project that actually contains vite.config.ts and scripts/vite-*.ts);
eslint on all three changed files (0 errors, 5 no-explicit-any warnings, matching the
file's existing convention for the untyped config object); check-changeset-presence,
check-changeset-no-major, check-control-bytes.

Changeset — and a correction

Empty frontmatter: nothing publishes. But the issue's premise for that conclusion is
wrong and should not be repeated. @object-ui/console is not a private app — it has no
private: true, carries publishConfig.access: "public", is one of the 40 packages in the
.changeset/config.jsonfixed group, and ships its built dist/ as a Hono UI plugin.
The reason nothing publishes here is narrower: this PR touches vite.config.ts and repo
tooling, neither of which is in the package's files list, and with the override unset the
guard evaluates the identical regex it did before, so the emitted bundle is unchanged.

Cross-references

Minor measurement note

The issue quotes eager chunks: 59/508. On this tree the same failure reports 60/508
(scratch fixture) and 58/507 (clean baseline re-run). The counts move with the tree and
the injected package; the failure and its cause are identical.

Generated by Claude Code


Generated by Claude Code

…spec test (#5388)
`assertLazyLinterStaysLazy` carried its own private `/@objectstack[\\/+]spec/`
while the `vendor-objectstack` chunk group three lines away already read the
widened test `resolveSpecDistInjection` publishes. Under OBJECTSTACK_SPEC_DIST
all 18 spec specifiers become absolute paths in the overriding tree, with no
`@objectstack` segment, so the private regex matched zero modules and the
counter-probe correctly refused a verdict — taking every injected console build
with it.
The producer now publishes `specModuleTest` alongside `vendorChunkTest`,
widened by one shared rule, and the plugin takes the spec test as a parameter.
The linter half keeps its literal regex and gains a counter-probe of its own:
its assertion is negative, so a blind LINT goes silently green forever.
Part of #5388
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDA9nN6nXQngoQUAAzRdMb
@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review-of-record — checked against the repo, not against the dev seat's report.

The card I wrote was wrong on one premise, and the correction stands.#5388 said apps/console is "a private app". Verified on origin/main: apps/console/package.json has no private: true, carries "publishConfig": { "access": "public" }, declares a files list, and @object-ui/console is in the fixed group at .changeset/config.json:43. So the empty-frontmatter changeset is right, but not for the reason I gave — the real reason is the narrower one in the PR body: vite.config.ts and repo tooling are not in the package's files, and with the override unset the guard evaluates the identical regex, so the emitted bundle is unchanged. My premise would have been a nice way to get the right answer and learn the wrong rule.

The specModuleTestvendorChunkTest decision is load-bearing, and I checked rather than took it.VENDOR_OBJECTSTACK_TEST on main is

/([\\/]node_modules[\\/]@objectstack[\\/](?!lint[\\/])|[\\/]@objectstack\+(?!lint@))/

— the whole @objectstack vendor scope minus the linter. Feeding that to the counter-probe would have made an eager @objectstack/client satisfy a probe whose entire job is to prove the walk can see the spec. The build would have gone green by lowering the bar, which is the one outcome #5388's acceptance ruled out, and it would have looked like a fix. Minting a separate specModuleTest off the same widen() is the right call and the test pinning the distinction is the part that keeps it true later.

The LINT decision is better than what the card asked for. I asked for a deliberate yes/no with a reason. The answer given — no injection-awareness (there is no producer to read; inventing one would be speculative and untestable), but a counter-probe on that half too — rests on an asymmetry the card missed: a blind spec test fails loud (the build stops, as it did), a blind lint test fails silent, because the assertion on it is negative and zero matches is indistinguishable from "properly lazy" while ~89 KiB gzipped ships on every page load. That is #5323's own reasoning turned on its own regex. Accepted as scoped.

Reverse verification is the real thing, not a re-run: the ablation removes the negative lookaheads so the vendor group claims the linter, and the build goes red on the linter message rather than the counter-probe — which is the correct signature, because reaching that verdict at all is what this fix restores. Both modes, ablation reverted, tree clean.

CI at b7b2b3b8c: 10 green (including all three changeset gates), 9 running, 2 skipped, 0 failures.

Staying a draft until CI converges. Once it lands, objectstack#10129 gets its bump re-run at the fixed commit — objectstack#10137 must not merge before then, because the SHA it currently pins is one the release pipeline cannot build.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-BInyohVQ.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.94KB113.61KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.07KB59.46KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.42KB1.39KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.07KB32.77KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)124.19KB30.20KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.30KB53.06KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)20.08KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.52KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-project-manager
os-project-manager marked this pull request as ready for review August 20, 2026 08:41
@os-project-manager
os-project-manager added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit 9a3daf8Aug 20, 2026
22 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-5388-lazy-linter-injection branch August 20, 2026 08:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-project-manager@claude