Skip to content

ci(gate): every imported package must be declared by the package that ships it (#4394) - #4407

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4394-phantom-dependency-gate
Aug 12, 2026
Merged

yinlianghui merged 2 commits into
mainfrom
claude/issue-4394-phantom-dependency-gate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4394

The trap being closed

A bare specifier resolving from a package directory says nothing about whether that package declares it. The workspace root carries devDependencies.react, so a node_modules/react symlink sits at the root and Node's — and TypeScript's — upward directory walk reaches it from any package directory. The card's own probe, re-run on this branch's tree:

require.resolve('react', { paths: ['packages/core/src'] })
  -> /home/user/objectui-4394/node_modules/.pnpm/react@19.2.8/node_modules/react/index.js
@object-ui/core declares 'react' in: (NO FIELD AT ALL)

pnpm's isolated node_modules prevents phantom dependencies between workspace packages; it does not defend a package against the root's own dev dependencies. So a dependency-direction check performed by resolution — the obvious way to "measure the actual import graph", which is what #4389's brief asked for — returns the wrong answer, confidently, and the disagreement stays silent until somebody else's install.

pnpm check:phantom-deps asks the other question, the one root hoisting cannot answer for anyone: is it declared.

Violation-surface reading, and the landing it picked

Measured first, over src/** of all 40 packages in the changeset fixed group — 2641 files, 12664 module specifiers. The full reading, by tier:

tier checked undeclared on main
runtime files (ship) 3539 package imports 7 sites / 2 distinct pairs
tooling files (test, bench) 3693 package imports 1873 sites / 76 pairs, of which 74 pairs are 4 root-installed tool names
node builtins 175 0 in a runtime file of a package without @types/node
self-references 4 0 (all in __benchmarks__, and core declares exports)

Landing: enforcing, no baseline ledger. The runtime surface was a small fixable set, and both entries are resolved in this PR rather than baselined:

  1. @object-ui/plugin-detail -> react-router-dom, 2 sites — a real defect, fixed. PermissionFacetLink.tsx:10 and record-reference-rail.tsx:22 take Link and useParams from the router; the manifest named it in no field at all, while all three siblings using the same hooks (app-shell, layout, plugin-designer) declare it as a peer of ^6.0.0 || ^7.0.0. Not latent: that package's rollup config externalises every bare specifier (external: (id) => !/^[./]/.test(id) && !id.startsWith(__dirname)), so the published dist/index.js carried an import the manifest never mentioned. Fixed with the siblings' range — a fourth spelling of the same claim is the packages/react-runtime 的 peerDependencies.react 是无上界的 >=18,允许尚不存在的 React 20+ #3741 mistake — plus a patch changeset, since a published manifest gaining a peer is user-visible.
  2. object-ui (the VS Code extension) -> vscode, 5 sites — graded, not fixed. The extension host injects it; it is not a runtime npm package and declaring it as a dependency would produce an uninstallable package. Handled as a HOST_PROVIDED entry that the package earns by declaring @types/vscode and engines.vscode, both re-checked on every run.

The tooling tier's 1873 is not baselined either, and the reason is in the shape of the number: 74 of its 76 pairs are exactly vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event — one coherent convention, this repo installing its test toolchain once at the root. Demanding thirty packages re-declare those is churn that protects no consumer, because nothing published resolves those files. So a tooling file may be answered by the package's own fields or the workspace root's, and the size of that concession is printed on every run (1878 tooling import(s) are declared only by the workspace root) rather than absorbed. What it still catches is the real thing: a test import of a package declared nowhere.

Pre-fix RED (#4118)

Two directions, both captured. First, the card's exact synthetic case — a throwaway packages/core/src/__phantom_probe_4394__.ts containing import { useState } from 'react' plus import type { ReactNode } from 'react', created and deleted without touching any existing file, and not present in this branch:

## 3. TypeScript is GREEN on it - resolution, not declaration, is what tsc asks
   diagnostics on the probe file: 0 (no TS2307 "Cannot find module" - it resolved)

## 4. The gate is RED on the same tree
      packages/core/src/__phantom_probe_4394__.ts:4:1  [undeclared-runtime]  @object-ui/core imports 'react' -> react
      packages/core/src/__phantom_probe_4394__.ts:5:1  [undeclared-runtime]  @object-ui/core imports 'react' -> react (type-only)
   (exit 1)

That is the false-GREEN in one frame: tsc reports zero diagnostics — not TS2307 — because it asked whether the module resolves, and it does.

Second, and stronger, the gate was red on unmodified main — this class was already live, so the pre-fix red needed no synthesis:

❌  2 import(s) are not declared where a consumer would find them (1 distinct package/dependency pair(s)):
      packages/plugin-detail/src/renderers/PermissionFacetLink.tsx:10:1  [undeclared-runtime]  ...
      packages/plugin-detail/src/renderers/record-reference-rail.tsx:22:1  [undeclared-runtime]  ...

Reverse verification

Predicted direction first, then run:

  • Delete the gate script -> the self-test cannot pass vacuously. Predicted red at the import, not at an assertion; measured Cannot find module '../check-phantom-dependencies.mjs', 0 test, exit 1. Restored.
  • Take the plugin-detail fix back out (git checkout origin/main -- packages/plugin-detail/package.json) -> gate exit 1, and exactly two self-tests red (has no phantom dependency, pins the defect this gate found), 40 of 42 still passing. Restored.
  • One bug this found in its own first run, now pinned as a regression. node:fs is not a legal package name — npm allows no : — so asking the package-name grammar before the builtin check filed all 127 node:-prefixed imports as path aliases and graded none of them. The verdict did not move; only a counter did (alias=127, builtin=48 instead of alias=0, builtin=175), which is why the self-test pins it by counter.

The gradings, each stated rather than buried

  • Type-only imports are STRICT — judged exactly like a value import. The emitted .d.ts keeps the reference, so a consumer type-checking against the package needs those types installed; it is one edit from becoming a value import with nothing reporting the promotion; and a carve-out here is the tolerant-consumer shape AGENTS.md #0.1 rejects. Measured cost today: zero — none of the 7 runtime violations was type-only, so strictness is free at the moment it lands.
  • Node builtins need @types/node in any of the package's own fields, devDependencies included — a types package is never a runtime import, so dependencies would be the wrong field. Measured: every builtin import in a package lacking it is in a tooling file (33 pairs, all of them); every runtime builtin import (@object-ui/cli's fs/path/url/child_process) is in a package that declares it.
  • @/… is skipped by GRAMMAR, not by an alias list — npm has no empty scope, so it cannot name a package.
  • @object-ui/console is the one exemption, and it is re-derived every run: an application build (vite build, no build.lib, no rollupOptions.external) inlines its ~325 devDependency imports into the dist/ bundle it publishes. If either property changes the exemption fails loudly instead of quietly covering a package that has started shipping bare imports.

Stated scope

src/** of the changeset fixed group, read from .changeset/config.json for the reason check-changeset-presence.mjs gives — a hand-written glob misses apps/console. Not checked: the reverse direction (a declared dependency nothing imports), files outside src/, and layering — "may @object-ui/core import React at all" is AGENTS.md §3 and #4389, and a core test importing React satisfies this gate while still being wrong. Answering a second question badly is worse than not answering it.

One honesty correction made during review: an earlier draft of the header claimed every build program excludes tests or emits nothing from them. #4006 has measured the opposite (73 *.test.d.ts in published dist/), and three packages list src in files. The claim that actually holds — and the one the tooling tier rests on — is that nothing resolves those files, so no install can fail on them. Header corrected to say that and to point at #4006; measurement added as a comment there rather than as a twin issue.

Verification

  • node scripts/check-phantom-dependencies.mjs — green on the merged tree (40 packages, 2648 files, 12700 specifiers).
  • pnpm exec vitest run scripts/__tests__/ — 39 files, 893 tests passed, including the new 42-test self-test and ci-cd-pipeline-doc.test.ts, which pins the doc job-table row against ci.yml in both directions.
  • pnpm type-check:scripts — clean (BundledApplication typedef added so the injectable exemption table types through allowJs).
  • pnpm exec eslint on both new files — clean. node scripts/check-control-bytes.mjs — OK; plus a grep -naP self-scan of every touched file for control bytes beyond the gate's surface (none).
  • node scripts/check-changeset-presence.mjs / check-changeset-fixed.mjs / check-changeset-no-major.mjs — green.
  • YAML re-parse proof for the ci.yml edit: parses; the new step sits at index 7 of the type-check job, immediately after pnpm install --frozen-lockfile at index 6, carrying the same if: steps.relevant.outputs.should_run == 'true' guard as its siblings.
  • pnpm install --frozen-lockfile — clean, so the 3-line lockfile change is in sync.
  • pnpm exec vitest run packages/plugin-detail/src/__tests__/PermissionFacetLink.sduiCarrier.test.tsx — 2 passed (the renderer whose import was undeclared).

Risks

  • The lockfile gains 3 lines. .gitattributes routes pnpm-lock.yaml through the pnpm-merge driver, so a concurrent branch conflicts textually rather than silently.
  • The gate is the first thing to run after install in type-check, so a phantom import now fails the job before the slower gates — intended, but it changes which red a contributor sees first.

Generated by Claude Code

claude added 2 commits August 12, 2026 03:17
… ships it (#4394)

A bare specifier RESOLVING from a package directory says nothing about whether
that package DECLARES it. The workspace root carries `devDependencies.react`, so
a `node_modules/react` symlink sits at the root and Node's — and TypeScript's —
upward walk reaches it from every package directory:

    require.resolve('react', { paths: ['packages/core/src'] })   -> resolves
    @object-ui/core declares 'react' in                          -> no field at all

So a dependency-direction check performed by resolution returns the wrong answer
confidently, and the disagreement stays silent until a consumer's install.

`pnpm check:phantom-deps` asks the other question, over `src/**` of every package
in the changeset `fixed` group, in two tiers: a RUNTIME file must name its imports
in a field a consumer installs (`dependencies`/`peerDependencies`/
`optionalDependencies`), while a test/benchmark file — which nothing published
resolves — may also be answered by the package's own `devDependencies` or the
workspace root's. Type-only imports are strict, node builtins need `@types/node`,
host-injected modules (`vscode`) earn the import by declaring their types plus
`engines`, and the one bundled application is an exemption whose two properties
are re-derived on every run.

The first full run was not green: `@object-ui/plugin-detail` imports
`react-router-dom` in two renderers and declared it nowhere, while its three
siblings using the same hooks all declare the same peer. Its rollup config
externalises every bare specifier, so the published `dist/index.js` carried an
import the manifest never mentioned. Fixed here with the siblings' range, which
is why the gate lands enforcing with no baseline ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 3:25am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index--CtYxYIt.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.20KB 108.43KB
core (index.js) 2.99KB 1.14KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.42KB 41.19KB
fields (index.js) 228.33KB 56.61KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 62.18KB 17.67KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 120.57KB 31.32KB
plugin-designer (index.js) 211.06KB 42.70KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 187.99KB 49.92KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.18KB 26.76KB
plugin-map (index.js) 18.05KB 5.80KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.99KB 10.74KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Copy link
Copy Markdown
Collaborator Author

ACCEPT — PM 复核 (session session_017Qqyix2QcnpUC9XeYVDzx3), closes #4394.

Flipping ready + arming auto-merge.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 03:39
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 456aac8 Aug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4394-phantom-dependency-gate branch August 12, 2026 03:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phantom dependency: react resolves from packages/core although core declares none — a resolution-based layering check returns the wrong answer

2 participants