Skip to content

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

Description

@yinlianghui

Observation-class finding, measured while doing objectui#4389's dependency-direction check. Nothing a user hits today — no source file currently takes the bad import, so nothing is broken. Filing so the trap is recorded rather than re-discovered by the next agent that measures this boundary.

What was measured

@object-ui/core is React-free by construction and by declaration:

  • packages/core/package.json — dependencies are @object-ui/types, @objectstack/formula, @objectstack/spec, lodash, zod. No react, and peerDependencies is empty.
  • packages/core/src — 0 files with a .tsx extension, 0 from 'react' imports, 0 exported hooks.
  • packages/core/node_modules/ — @object-ui, @objectstack, lodash, typescript, vitest, zod. No react.

AGENTS.md section 3 pins the constraint in words too: core is "No UI-lib deps. Logic only."

And yet resolution succeeds:

$ node -e "console.log(require.resolve('react', { paths: ['packages/core/src'] }))"
/home/user/objectui-4389/node_modules/.pnpm/react@19.2.8/node_modules/react/index.js

Root cause is benign and permanent: the root package.json carries devDependencies.react (19.2.8, needed by the DOM vitest projects), so a node_modules/react symlink exists at the workspace root:

node_modules/react -> .pnpm/react@19.2.8/node_modules/react

Node's — and TypeScript's — upward directory walk reaches that symlink from any package directory. pnpm's isolated node_modules prevents phantom deps between workspace packages, but not against the root's own dev dependencies.

Why it matters

A dependency-direction check performed by resolution ("can core import react? let me resolve it — yes") returns the wrong answer. The authoritative check is the package's own declared dependencies / peerDependencies, not whether the specifier resolves in a hoisted workspace. The two disagree here, and the disagreement is silent: a react import added to packages/core would typecheck, build, and pass every local suite, then break the published @object-ui/core for any consumer that does not already have React installed — the classic phantom-dependency failure, discovered at install time by someone else.

This is not hypothetical as a method error: objectui#4389's brief asks the implementer to "measure the actual import graph", and the resolution probe is the obvious way to do that.

Not proposing a fix here

For triage. The shape a fix could take, cheapest first:

  1. Nothing — record the trap in AGENTS.md beside the topology table, so "measure the import graph" is understood as "read the declared deps".
  2. A check:* script asserting that every bare-specifier import under packages/*/src resolves to a dependency that package declares (dep, peerDep, or devDep for test files). This is the mechanical version, and it would also catch the reverse direction.
  3. An ESLint no-restricted-imports / import/no-extraneous-dependencies rule per package. Heavier config surface than 2 for the same property.

Related in family, both closed: objectui#3367 (toPredicateInput duplicated core-side and react-side, resolved by having react re-export core — i.e. the layering direction this finding is about) and objectui#3741 (an unbounded peerDependencies.react range).


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 12, 2026
  2. yinlianghui commented on Aug 12, 2026

    @yinlianghui
    CollaboratorAuthor

    CLAIM + RULING — PM seat session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4394-phantom-dependency-gate, one dev agent, one PR, Fixes #4394. finding → pm:queue.

    Ruling (delegated, veto window open): gate it. This trap has now been live-fired once — a resolution-based direction check during #4389 would have returned false-GREEN on the exact cycle that card stopped on, and a react import in @object-ui/core would typecheck, build, and pass every local suite before breaking published consumers. That is the "decide by it, decide wrong" class with the cost one near-miss away. The fix is a declared-dependencies gate in the repo's own check-*.mjs dialect:

    1. For each published package, every bare-specifier import in src/** must resolve against the package's OWN declared dependencies/peerDependencies (plus node builtins where @types/node is declared, and type-only imports graded explicitly — measure how the repo treats import type from undeclared packages and pin whichever is honest).
    2. Measure the existing violation surface FIRST and let the reading pick the landing: zero violations → gate lands enforcing; a small fixable set → fix in the same PR with per-fix justification; a wide surface → gate lands with an explicit baseline ledger (the drift-ledger precedent) and the burn-down is a follow-up card. Do not silently baseline what a one-line dependency declaration would fix.
    3. Guard-class → The report config panel is titled "Title" and the empty state says "Object Not Found" — call sites asking for a key whose value is not the copy for that slot #4118 pre-fix red required: the gate run against a synthetic phantom import (or the pre-fix tree if violations exist) shown red in the PR body.
    4. Self-test per the repo's gate convention, wired into the same CI lane as the sibling check-* gates.

    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions