Repository navigation
finding(tooling): nothing gates a DECLARED-but-never-imported dependency — check:phantom-deps only judges the opposite direction #8198
Description
Activity
- addeddomain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repoobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
on Sep 7, 2026 Claim: PM loop round R46 —
domain:devx @ objectuiexecution seat.
Session:session_01FhBNJcLRZLe8M87VcUgpKr
Branch:claude/issue-8198-unused-declared-deps-gate
Worktree:/home/user/objectui-issue-8198
Domain:domain:devx
File surface: a newscripts/check-unused-dependencies.mjs(or a second direction insidescripts/check-phantom-dependencies.mjsif the four-axis reading says the shared machinery makes one file the honest home — the dev decides and states it), its test underscripts/__tests__/, and onecheck:*line in the rootpackage.jsonscripts block. Package manifests only where the census finds a declaration with no importer AND the removal is uncontroversial (a stated allowlist reason is the alternative, never a silent skip). ⛔ Not.github/workflows/**, notAGENTS.md.
Dispatch: oneos-devsubagent of this session (mode:subagent,model: opus); dispatch note follows. Labelspm:queue→pm:dispatchedand assignee set in one write, read back at 2026-09-07T08:18Z.
Generated by Claude Code
Dispatched —
domain:devx @ objectuiexecution seat, PM sessionsession_01FhBNJcLRZLe8M87VcUgpKr, R46, 2026-09-07T08:19Z. Devs in flight 3/3 (#5174 batch 36, #7307 batch 6, this); PRs #8276, #8277 and #8282 are in landing.Dev. One
os-devsubagent of this session on branchclaude/issue-8198-unused-declared-deps-gate, worktree/home/user/objectui-issue-8198, baseorigin/mainatfc32921aaor newer. Race re-read at 08:18Z: the newestClaim:on this card is5567572580, naming that branch.Brief, in one paragraph. Measurement first over the released population
check-phantom-dependencies.mjsalready defines (its exporteddiscoverPackages/readReleaseGroup/moduleSpecifiers/packageNameOf, so the two directions cannot disagree about what an import is): every declared runtime key with no importing source file,devDependenciesmeasured separately and gated only if the four axes justify it; the card's control (lucide-reactincomponentsimports) plus a planted unused key; every hit classified (CSS-only, config-consumed, type-only, side-effect, re-exported peer,exports/bin-referenced) into either a detection rule or an allowlist row with a reason — ⛔ no blanket ignore. The Atoms constraint (AGENTS.md:61) gets its own reading — runtime deps of@object-ui/componentstoday — and an explicit assertion only if the tree already satisfies it. The gate names package/field/key, allowlist rows self-delete, entry guard viainvoked-as.mjs; home (new script vs a second direction inside phantom-deps) decided under the four axes; onecheck:*line in the rootpackage.json, merged over #8277's line after it lands; ablation on the committed tree (planted key → RED, dropped allowlist row → RED, unmodified → GREEN). PR first lineFixes #8198when the gate holds for the whole population, elsePart of #8198with the residue's cards.Premise check (read 08:18Z on
fc32921aa): the card's live instance is gone (#7625 closed;rechartsis declared only byplugin-charts, which imports it), so the positive control is planted;check-phantom-dependencies.mjs(788 lines, last touched by #6145) exports the machinery listed above and has its own test;check:phantom-depsis atpackage.json:46. Noted, not this seat's to repair: #7625 is CLOSED withpm:dispatchedstill on (domain:ui).
Generated by Claude Code
os-dev-report
{ "issue": 8198, "status": "done", "branch": "claude/issue-8198-unused-declared-deps-gate", "pr": "https://github.com/objectstack-ai/objectui/pull/8304", "base_sha": "fc32921aa204e2ce75acb436801b5ccbb3efbe82", "final_head": "e2409120f6eb2cfadc0a196ba1f6af3e68621073", "premise_still_valid": true, "home_decision": { "choice": "new file: scripts/check-unused-dependencies.mjs, importing every parser helper from check-phantom-dependencies.mjs", "reason_by_axis": { "business": "MEASURED, and it decided the call: the two directions need OPPOSITE file populations, which is the reason phantom-deps itself gave for leaving this direction alone. phantom-deps reads src/ only and must (a vite.config.ts import is not a shipped import); this gate must read the whole package (a dependency consumed only by a build config, a stylesheet or a bin/ helper IS consumed). The wider scan is what clears 12 of the 41 hits. Exit codes decide it too: phantom-deps is green with 0 findings, this direction had 41 on the base, so one exit code would have meant holding phantom-deps hostage to a cleanup or landing this gate non-enforcing.", "long_term": "contract-first: one gate, one question, one verdict. Machinery is imported, not duplicated (discoverPackages, readReleaseGroup, moduleSpecifiers, packageNameOf, isBuiltin, SKIP_DIRS, SOURCE_FILE), and a pin test asserts the import so the two directions cannot come to disagree about what an import is.", "ai_safety": "a red names one question, with package + field + key + the three scans that found no consumer. Fusing a second direction into an 800-line file makes both harder to act on.", "startup_focus": "a new file plus one script line is the SMALLER surface, not the larger: fusing would be the same code in a bigger file plus a per-question switch." } }, "census": { "packages": 40, "declared_keys_by_field": { "dependencies": 361, "optionalDependencies": 0, "peerDependencies": 89, "devDependencies": 345 }, "unused_by_field": { "dependencies": 41, "optionalDependencies": 0, "peerDependencies": 22, "devDependencies": 67 }, "classes": [ { "class": "stylesheet-only (@plugin / @import in .css)", "count": 4, "disposition": "DETECTION RULE — CSS at-rules read from .css/.scss/.sass/.less" }, { "class": "type-only (@types/X whose X is imported)", "count": 1, "disposition": "DETECTION RULE — @types/X follows X, npm's @types/scope__name mangling inverted" }, { "class": "global-scope types (@types/node)", "count": 0, "disposition": "NAMED CARVE-OUT (GLOBAL_TYPES_PACKAGES), counted every run — its consumer is process/Buffer/NodeJS, which no import scan can see. Zero instances in a gated field today; the carve-out exists because demanding a builtin import would red a package for reading an environment variable. Found by the pin test, not by inspection." }, { "class": "consumed only outside src/ (build config, bin/ helper)", "count": 12, "disposition": "DETECTION RULE — the whole package directory is scanned; this is the property that makes it a second gate" }, { "class": "emitted into GENERATED source as a string literal", "count": 2, "disposition": "ALLOWANCE with verify (@object-ui/cli -> components, react)" }, { "class": "inlined by a bundler named in its own config (tsup noExternal)", "count": 2, "disposition": "ALLOWANCE with verify (object-ui -> @object-ui/core, @object-ui/types)" }, { "class": "genuinely dead — name appears nowhere in the package but its manifest and CHANGELOG", "count": 37, "disposition": "REMOVED in this PR, each verified by a whole-package grep first" }, { "class": "string key in a postcss.config / tailwind.config plugin map", "count": 0, "disposition": "NO RULE, deliberately — a real class in the wild with zero instances here; written into the header so the next reader knows it was considered rather than missed" }, { "class": "peerDependencies with no consumer", "count": 22, "disposition": "MEASURED, NOT GATED — a peer is a constraint on the HOST's graph, not a claim to import; 21 of 22 are react-dom. Filed as objectui#8300. Count printed every run." }, { "class": "devDependencies with no consumer", "count": 67, "disposition": "MEASURED, NOT GATED — a consumer's install does not fetch them; dominated by toolchain named BY STRING in a config. Count printed every run." } ], "live_unused": [ { "package": "@object-ui/plugin-designer", "field": "dependencies", "key": "@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, @object-ui/fields", "disposition": "removed" }, { "package": "@object-ui/plugin-chatbot", "field": "dependencies", "key": "react-markdown, react-syntax-highlighter, remark-gfm", "disposition": "removed (plus the orphaned @types/react-syntax-highlighter from devDependencies)" }, { "package": "@object-ui/plugin-report", "field": "dependencies", "key": "@object-ui/plugin-grid, clsx, react-i18next, tailwind-merge", "disposition": "removed" }, { "package": "@object-ui/plugin-map", "field": "dependencies", "key": "@objectstack/spec, lucide-react, zod", "disposition": "removed — all three appeared only in prose comments and a dead vite globals row" }, { "package": "@object-ui/runner", "field": "dependencies", "key": "class-variance-authority, clsx, tailwind-merge", "disposition": "removed" }, { "package": "@object-ui/layout", "field": "dependencies", "key": "clsx, tailwind-merge, react-dom", "disposition": "removed — react-dom was an exact pin alongside a peer range, i.e. a library hard-depending on the renderer it asks its host to supply" }, { "package": "@object-ui/plugin-dashboard", "field": "dependencies", "key": "clsx, tailwind-merge, react-dom", "disposition": "removed — same react-dom defect" }, { "package": "@object-ui/plugin-ai", "field": "dependencies", "key": "@object-ui/react, clsx, tailwind-merge", "disposition": "removed" }, { "package": "@object-ui/core", "field": "dependencies", "key": "lodash, zod", "disposition": "removed — both appeared only in comments" }, { "package": "@object-ui/fields", "field": "dependencies", "key": "clsx, tailwind-merge", "disposition": "removed" }, { "package": "@object-ui/console", "field": "dependencies", "key": "@object-ui/react-runtime, sucrase", "disposition": "removed" }, { "package": "@object-ui/auth", "field": "dependencies", "key": "@object-ui/types", "disposition": "removed" }, { "package": "@object-ui/plugin-calendar", "field": "dependencies", "key": "@object-ui/fields", "disposition": "removed" }, { "package": "@object-ui/plugin-editor", "field": "dependencies", "key": "@object-ui/react", "disposition": "removed" }, { "package": "@object-ui/plugin-markdown", "field": "dependencies", "key": "@object-ui/react", "disposition": "removed" }, { "package": "@object-ui/react", "field": "dependencies", "key": "react-hook-form", "disposition": "removed" }, { "package": "@object-ui/cli", "field": "dependencies", "key": "@object-ui/components, @object-ui/react", "disposition": "ALLOWED with verify — emitted into generated app sources as string literals by src/utils/app-generator.ts and src/commands/init.ts; objectui dev runs that generated app against the CLI's own install" }, { "package": "object-ui (vscode-extension)", "field": "dependencies", "key": "@object-ui/core, @object-ui/types", "disposition": "ALLOWED with verify — tsup.config.ts noExternal inlines both into the extension's dist" } ], "install_effect": "pnpm install after the removals reported `Packages: -10`" }, "controls": { "positive_planted": "LEG 1 of the ablation planted `ghost-dependency-8198` in packages/core dependencies: gate EXIT=1 and named the key. On-disk marker count 0 to 1 proves the mutation landed.", "card_control_lucide_react": "lucide-react in packages/components: CONSUMED (import), first at packages/components/src/custom/combobox.tsx; 70 files by the card's own grep shape — matching its 0c8dbc492 reading, so the control fires on this tree too.", "card_control_recharts": "recharts in packages/components: NOT CONSUMED, 0 files — and no longer declared (already removed by objectui#7625), so it is the true negative this gate would have caught. The zero is real, not a broken scan: the lucide-react control was run with the same command shape in the same run.", "css_rule_control": "tailwindcss-animate in packages/components: CONSUMED via css at-rule at packages/components/src/index.css. Asserted on the REAL tree in the pin test, because a regression in the CSS rule is silent in fixtures and loud only here." }, "atoms_reading": { "runtime_deps_today": "48 in @object-ui/components: 6 workspace siblings, @objectstack/spec, 28 Radix primitives, and 13 Shadcn upstream libraries (cmdk, date-fns, embla-carousel-react, input-otp, lucide-react, next-themes, react-day-picker, react-hook-form, react-resizable-panels, sonner, vaul, the cn() trio, tailwindcss-animate). Every one has a consumer; the package is green under the generic scan.", "assertion_added": false, "reason": "Decided on the four axes and NOT added, on the card's own instance as evidence. (1) 实际业务需求: a named allowlist would NOT have caught recharts — recharts was legitimately on such a list the day it was added, because chart.tsx imported it; the defect was that the importer went away. The generic unused-declaration scan is what catches that, so an allowlist is strictly WEAKER for the measured instance. (2) 长远合理性: 'zero heavy 3rd-party deps' has no measurable definition in this tree — no byte budget, no list, no gate reads a size — so a 48-name allowlist would be a ledger with no rule behind it, edited every time a legitimate Shadcn primitive lands. (3) 防AI犯错: a list an agent edits to make a red go away is a weaker contract than a rule it cannot argue with. (4) 创业不扩散: no pull; the tree satisfies the constraint today. Defining 'heavy' is a maintainer decision, not a rider — reported, that half stopped, as the dispatch instructed." }, "gate": { "mode": "strict", "gated_fields": ["dependencies", "optionalDependencies"], "allowlist_rows": 4, "self_deleting": true, "self_deleting_conditions": [ "the package left the released population", "the key is no longer declared in a gated field (the row outlived the declaration it excused)", "the key now HAS a visible consumer (the row is no longer NEEDED) — proved live by ablation LEG 3", "the row's verify() finds its evidence gone" ] }, "ablation": { "method": "on the committed tree, one script with `trap restore EXIT INT TERM`, absolute REPO_ROOT paths, HEAD blob hashes compared before each leg (empty hash treated as FAILURE), each mutation proved on disk by a marker count before/after, each restore proved by `git diff HEAD` being empty — never by a git checkout exit code. No dist involved: the gate is a scripts/ module node runs from source.", "legs": [ { "leg": "0 unmodified", "exit": 0, "observed": "green" }, { "leg": "1 plant an unused dependencies key in packages/core", "exit": 1, "observed": "marker 0 to 1; output names ghost-dependency-8198 with package, field and the three scans" }, { "leg": "2 delete the live allowance row @object-ui/cli -> @object-ui/components", "exit": 1, "observed": "marker 1 to 0; reports @object-ui/cli declares '@object-ui/components' — the row is load-bearing, not a phantom allowance" }, { "leg": "3 add an allowance for a key that IS consumed", "exit": 1, "observed": "marker 0 to 2; stale-allowance, 'now HAS a consumer the scan can see (packages/plugin-list/src/components/TabBar.tsx, import)' — the self-deleting condition, proved live" }, { "leg": "4 unmodified again, same run", "exit": 0, "observed": "green" } ] }, "files_changed": [ "scripts/check-unused-dependencies.mjs (new)", "scripts/__tests__/check-unused-dependencies.test.ts (new, 37 cases)", "package.json (one check:unused-deps line beside check:phantom-deps)", ".changeset/8198-unused-runtime-declarations-removed.md (new, minor)", "16 package manifests (37 dependencies keys + 1 orphaned @types key removed)", "pnpm-lock.yaml", "content/docs/guide/objectos-integration.mdx (the one red the removals caused — see deviations)" ], "tests": "Ablation above. Suites, all foreground, exit codes captured before any pipe: `pnpm exec vitest run scripts/__tests__/` EXIT=0, 117 files / 3459 tests. `pnpm exec vitest run packages/auth packages/core packages/fields packages/layout packages/react packages/runner packages/cli` EXIT=0, 393 files / 6379 tests. The nine changed plugins + vscode-extension EXIT=0, 222 files / 1971 tests. `pnpm exec vitest run apps/console/` EXIT=0, 89 files / 1069 tests. Build: `pnpm exec turbo run build --filter=!@object-ui/site --concurrency=2` EXIT=0, 43 successful / 43 total, run BEFORE any judgement so no stale dist could lie in either direction. Gates (verdict lines, not bare $?): check:unused-deps 0 green; check:phantom-deps 0 green; check:doc-snippets 0 (624/624 blocks, root bound clean); check-doc-links 0; check:doc-types 0; check:doc-fences 0; check:spec-floors 0; check:dist-completeness 0; check:self-import 0; check:side-effects-array 0; check:entry-guard 0; type-check:scripts 0; lint:root 0 (0 errors, 32 pre-existing no-explicit-any warnings); check:control-bytes 0 plus a manual control-byte grep over the new files (no match); check-changeset-presence/no-major/fixed/overwrite all 0; check-governed-queue-guard --test over all 25 changed paths 0 NOT GOVERNED; check-comment-mask-corpus 0 after the merge. One gate NOT green and NOT this PR's: check-node-esm-load EXIT=1 on its provenance leg — turbo's shared cache replayed 5 packages built by a SIBLING WORKTREE in this container (objectui-issue-5174-b36: mobile, permissions, providers, react-runtime, types). None of those five has a dependency removed here; its load leg graded 29 entries with no failure outside its by-design list. CI builds fresh.", "changeset": ".changeset/8198-unused-runtime-declarations-removed.md — minor, naming the 16 affected packages. check-changeset-presence.mjs says none is OWED (dependencies is not one of the eight published-contract fields it watches), and it was written anyway: removing a dependency is potentially breaking for a consumer who imports one of these packages while depending only on the ObjectUI package that used to drag it in. Per the version policy, breaking semantics ship as minor with the semantics spelled out, never major. check-changeset-no-major green.", "gates": "See `tests`. Enforcement note: this gate has NO ci.yml step, because .github/workflows/** was outside the declared file surface. It IS enforcing today through its pin test's repository-green case, which pnpm test runs in CI. Filed as objectui#8301 with the exact YAML.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST; issues and comments read from the REST list endpoint, dedup done with local grep.", "filed": [ "filed as #8300: 21 packages declare a react-dom peer no file in them consumes — the convention needs a maintainer decision, not a gate (this is why peerDependencies is measured but not gated)", "filed as #8301: check:unused-deps has no ci.yml step; it is enforced only through its pin test, unlike its sibling check:phantom-deps", "filed as #8302: six plugin vite.config.ts files carry alias and output.globals rows for packages the plugin never imports", "filed as #8303: @object-ui/layout and @object-ui/plugin-dashboard pin react in dependencies while also asking for it as a peer — the same defect shape as the react-dom entries removed here, but invisible to this gate because react IS imported" ], "deviations": [ "ONE FILE OUTSIDE THE DECLARED SURFACE, and it was forced: content/docs/guide/objectos-integration.mdx. Removing react-dom from @object-ui/layout's dependencies turned scripts/__tests__/check-doc-snippet-types.test.ts red — that gate builds its typings map from dependencies only, so the snippet's react-dom/client import had been resolving on layout's incorrect entry. Putting the entry back was refused on that gate's own ruling, quoted verbatim in its header: 'a manifest is a claim about what a package needs, and a doc gate's convenience is not that claim' (maintainer, 2026-08-24). The snippet now exports App and stops at the component boundary; the mounting step is prose. Adjacent context: objectui#8059.", "No ci.yml step for the new gate — .github/workflows/** was out of surface. Enforced meanwhile by the pin test; filed as objectui#8301.", "@types/react-syntax-highlighter was removed from packages/plugin-chatbot devDependencies. devDependencies are NOT gated, so this was not required; it is orphaned by the react-syntax-highlighter removal in the same PR and leaving it would have been incoherent dead weight caused by this change.", "PR #8277 (check:comment-mask-corpus, same scripts block) landed mid-flight and was MERGED, not rebased, per the branch-hygiene rule. Both script lines are present; every gate was re-run after the merge." ], "open_questions": [] }
Generated by Claude Code
LANDED: PR #8304 merged at 2026-09-07T09:52:33Z as
0ea7054f8; content probe on re-fetchedorigin/mainpassed (check:unused-depsscript line andscripts/check-unused-dependencies.mjspresent;lodashgone from@object-ui/core's manifest with@objectstack/specas the control). On main now: the reverse-direction gate overdependencies+optionalDependenciesof every released package (4 allowances withverify(), self-deleting), the 37 dead declarations removed across 16 packages (pnpm install−10 packages),peerDependencies(22 unused) anddevDependencies(67) measured and printed but not gated, and the Atoms constraint measured (48 consumed runtime deps incomponents) but not asserted — "heavy" has no definition in the tree, and a named allowlist would not have caught this card's ownrechartsinstance. Closed viaFixes;pm:dispatchedstripped in the same stroke. Follow-ups: #8300 #8301 #8302 #8303 (finding). —domain:devx @ objectuiseat, PM sessionsession_01FhBNJcLRZLe8M87VcUgpKr, 09:55Z.
Generated by Claude Code
Split out of objectui#7625 rather than fixed there — triage on that card flagged it explicitly as its own work, not that card's:
The gap
scripts/check-phantom-dependencies.mjs(pnpm check:phantom-deps) judges imports that are not declared. Its own summary line names the direction:The reverse direction — a package declaring a dependency no file in it imports — is judged by nothing in CI. Nothing goes red when a declaration outlives its last consumer, and, equally, nothing goes red if a declaration that IS needed gets removed. Both directions of that mistake are currently invisible to the farm.
The live instance this was found on
objectui#7625:
rechartsstayed declared inpackages/components/package.jsonfor the whole window after objectui#7397 deletedpackages/components/src/ui/chart.tsx, its only consumer in that package. Measured on0c8dbc492, with the positive control the zero needs:The declaration was removed by hand on objectui#7625. Nothing would have reported it, and nothing reports the next one.
Why it matters more for this repo than for a generic monorepo
AGENTS.md section 3 constrains
@object-ui/components— the Atoms package — to "Shadcn primitives, zero heavy 3rd-party deps", and routes heavy widget dependencies to@object-ui/plugin-*. That is a written layering constraint whose only enforcement today is that somebody notices. objectui#7625 is one instance of it being violated for a reason that had already disappeared; a gate is what makes the constraint mechanical rather than remembered.The cost of an unused declaration is install-graph weight for every consumer of the package, not shipped bytes —
@object-ui/components' Viteexternalpredicate is path-based and never readsdependencies, so the built artifact is unaffected either way. That is what makes it easy to miss: no bundle budget moves, no test fails.Design notes for whoever picks this up (not a spec)
dependenciesappears in some import specifier" — has known false positives that need a decision before implementation, not during: CSS-only packages (tailwindcss-animate), packages consumed through a bundler alias or a config file rather than an import, type-only packages, and packages loaded by side effect. An allowlist with a stated reason per entry is the usual answer;check-phantom-dependencies.mjsalready carries the scanning machinery and the released-package set to build on.Dedup: searched open and closed; the near neighbours are objectui#4394 (phantom dependency, the opposite direction), objectui#3943 (
sideEffectsconsistency) and objectui#3663 (filesentries exist on disk) — all closed, none covering this direction.