unsloth/tests/studio/test_frontend_dep_removal.py
Michael Han bc23135996
Unsloth: appearance palettes, customization options, and control restyle (#7077)
* Unsloth: appearance palettes, customization options, and control restyle

Adds Standard, Classic, and Minimal color palettes to Appearance settings,
each adapting to light and dark mode. Classic is a neutral enterprise look
that reserves its blue accent for toggles, badges, and focus rings; Minimal
is strictly black, grey, and white.

Adds customization options scoped to the active mode: accent, background,
and foreground colors with an in-app color picker, UI and code fonts with a
searchable dropdown covering bundled, device, and imported fonts, font file
import, UI and code font sizes, contrast, pointer cursors, reduce motion,
font smoothing, and translucent sidebar. Settings persist through the
personalization API with backend validation and sync across devices.

Restyles core controls for a cleaner, flatter look in both modes: bordered
white input fields, fully rounded pills for single-row controls, no drop
shadows, simple straight-line chevrons replacing all rounded arrow icons,
and consistent hover tones in dropdown menus. Popovers now portal into the
open dialog so their lists scroll correctly inside modal dialogs.

Moves Language into General settings and Chat defaults into the Chat tab
above the Canvas section.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Unsloth: appearance follow-ups, font options, and settings search

Neutralizes focus and selection rings across all palettes so highlighted
elements, including typing boxes and the selected palette card, never take
the accent color. The custom accent no longer recolors rings.

Restyles the color controls as filled pills showing the hex value inside,
with text and border contrast picked from the color's luminance. Menus in
popovers now match the app's dropdown menus: rounded-lg corners, tighter
padding, accent hover rows, and a bordered search field. Popovers inside
modal dialogs are modal so their lists scroll with the wheel. Outline
buttons share the same dark fills as dropdown triggers.

Adds heading and chat font options next to the UI and code fonts, each
using the searchable font dropdown and persisting through the
personalization API. Removes the translucent sidebar option end to end.

Adds settings search: a search field at the top of the settings sidebar
that filters setting names across every tab, grouped by tab with icons,
and jumps to the tab on click.

* Unsloth: use the shared accent token for dark hover fills

The settings dialog nav, its close button, the model selector, and the
project switcher hovered with hardcoded blue tinted greys (#3a3d43,
#2d2e32) in dark mode while every menu and sidebar uses --accent. All
hover and active pill fills now use the accent token so dark hovers are
the same everywhere and adapt to the active palette.

* Unsloth: settings search polish and jump to matched setting

Widens the settings dialog to 880px and the sidebar column to 248px so
the search field has more room. The search pill aligns with the left
start of the Settings title, gets more spacing above and below, and its
icon and placeholder sit slightly further left.

Search results now jump to the exact setting: rows and sections expose
their label as a data attribute, and picking a result opens the tab,
scrolls the matched row into view, and flashes it briefly.

* Unsloth: settings search bar spans the full nav pill width

The search field now starts and ends at the same edges as the nav hover
pills instead of being inset to the title text.

* Unsloth: address review findings on motion, sync, and font limits

Reduce motion Off now opts back out of the OS reduced-motion preference
for CSS animations via a force-motion class that the media rules skip,
and forcing reduce motion On keeps the loader exceptions (spinners,
loading dots, progress bars) animating.

When the color scheme follows the system, the resolved mode is now part
of the theme store snapshot, so an OS scheme flip re-renders consumers
and reapplies per-mode custom colors instead of leaving stale inline
variables from the previous mode.

Imported fonts get an aggregate size cap (4.4M characters) on both the
frontend sanitizer and the backend model so the persisted store always
fits browser localStorage quotas, with a clear error toast when an
import would exceed it. Backend validation also tightens imported font
names (rejects CSS delimiter characters) and requires strict base64
font data URLs, matching the frontend patterns.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Unsloth: profile toggle to hide the sloth in the chat greeting

Adds a Show greeting sloth switch to Settings > Profile. The chat welcome
hides the mascot when it is off. The preference persists locally and
through the personalization API, with backend validation and tests, and
the row is reachable from settings search in all four locales.

* Unsloth: control restyle, dropdown scrolling, and palette consistency

Settings sidebar puts search on top with the tab list under a small
Settings label. Combobox popups scroll with the wheel inside dialogs by
falling back to manual list scrolling while a dialog scroll lock is
active, and the local model selector popover became modal for the same
reason. Number inputs swap native spinners for a shared grey stepper
that clamps to min, max, and step. Run settings fields in light mode use
the same white fill and border as the settings dialog. Selection and
focus rings derive from each palette's border color instead of near
black, hover borders soften the same way, the Classic sidebar stays
white like Standard, decorative greens follow the palette accent, and
meaning-carrying marks like the hub verified badge keep the brand green
in every palette.

* Unsloth: palette card selection keyed off the palette attribute

Switching palettes restyles the whole page the moment data-palette lands
on the html element, but the React re-render that moves the selection
classes arrives later, so the ring and check briefly stayed on the
previous card with the new palette's colors. The active ring and check
now key off html[data-palette] in CSS, so they swap in the same style
pass that swaps the tokens. Also adds breathing room around the settings
search bar and under the Settings label, shortens the greeting sloth
description, and renames the avatar section to Or pick a sloth profile
picture in all locales.

* Unsloth: restore neutral rings, drop the palette check, sidebar spacing

Puts the ring tokens back to their fixed per palette values and removes
the hover border darkening, undoing the derived border experiment. The
selected palette card no longer shows a check since the ring already
marks it. The settings sidebar search bar, nav pills, and search results
get a little side padding, and the Settings label lines up with the pill
text.

* Unsloth: indicator restyle, sidebar menu customization, edge fade toggle

- Derive focus and selection rings from the border color so indicators
  stay 1px and adapt to every theme and palette
- Suppress mouse focus rings except on pressed controls to remove the
  selection flash on the avatar and palette pickers
- Defer settings panel rendering so the active nav pill updates instantly
- Customizable sidebar user menu with drag to reorder and shortcuts to
  the settings tabs
- Grey hover for the standard light palette instead of green
- Borderless controls in dark mode with fill based focus states
- Profile picture: no picture option, pencil edit icon, atomic selection
- Font dropdowns: narrower triggers and the resolved default shown as
  Inter Variable (Default)
- System prompt border darkens on focus
- New appearance setting to swap edge fades for thin divider lines
- Move the theme bootstrap to an external script to satisfy CSP

* Unsloth: harden theme boot and Firefox scroll container focus

- Guard the theme and palette storage reads separately so a blocked
  localStorage (private browsing) still resolves a mode from the OS
  preference instead of skipping the boot entirely
- Firefox makes scrollable containers keyboard focusable and drew its
  3px UA outline on them; swap it for the app's soft 1px indicator

* Unsloth: make the UI and code font settings reach the font utilities

The theme block declared the sans and mono stacks as literals, so
Tailwind inlined them into every font-sans and font-mono utility at
build time and the runtime overrides from Settings > Appearance never
applied. Reference the :root tokens instead, matching how the color
tokens already work.

* Unsloth: in-dropdown font upload, accent meters and avatar, naming cleanup

- Move font importing into each font dropdown: Upload and Select folder
  sit side by side under the list, imported fonts get an inline remove,
  and the standalone Import font row is gone
- Uploads reuse fonts the user already has (bundled, imported, or
  installed, matched by file name with style suffixes stripped) instead
  of embedding a duplicate copy; only new fonts are embedded
- Folder scan lists font files from a picked folder in every dropdown
  for the session; picking one imports it through the same path
- Fallback avatar uses the control accent with a readable foreground
  instead of the neutral primary that rendered black outside standard
- Monitor bars, progress defaults, sliders, and usage meters use the
  control accent; warning and danger tiers stay amber and red
- User facing strings that called the app just Studio now say Unsloth
  in all four locales, keeping Unsloth Studio and LM Studio intact

* Unsloth: left align the font upload actions and divide them

Upload and Select folder now read from the left like the list items,
with a short vertical rule between the two.

* Unsloth: keep sliders neutral and the chat greeting on Hellix

- Sliders are controls, not meters, so their fill goes back to the
  neutral primary instead of the palette accent
- The base h1 rule reads --font-heading with !important and the chat
  thread root resets that variable to the sans stack, which pulled the
  greeting off Hellix; restore the stack on the greeting element

* Unsloth: move the None avatar cell last and keep footer actions on one line

- None sits after the sloth pictures instead of leading the grid
- Upload shrinks to its label so Select folder no longer wraps

* Unsloth: size the folder action to its label

Both footer actions now hug their content so the hover pill does not
stretch across the leftover row width.

* Unsloth: separators only between unrelated settings clusters

Rows inside a titled section are related, so the per row divide-y is
gone from SettingsSection. A SettingsGroupDivider marks the two real
boundaries in the theme section (colors to fonts, fonts to contrast)
and the Clear all chats row gets its destructive border back now that
divide-y no longer draws one for it.

* Unsloth: balance the two font upload actions

Both actions share the footer row evenly again; nowrap keeps Select
folder on one line at the narrower width.

* Unsloth: drop the theme section dividers and split the chat menu groups

The colors, fonts, and contrast rows read fine without rules, and the
chat menu gains its one real boundary between the pin toggles and the
disclaimer rows.

* Unsloth: normalize oversized sidebar menus and reject newline font data URLs

Two backend validation fixes in PersonalizationCustomization:

- sidebarMenu refused any list longer than the number of distinct ids
  because Field(max_length) is enforced before the dedupe validator runs.
  A stale or duplicated payload that would normalize to one entry per id
  was rejected outright, defeating the normalizer that exists for exactly
  that case. Cap the incoming list at a generous multiple so it reaches
  the validator; a pathologically long list is still refused.

- The imported font dataUrl validator used re.match on a pattern ending
  in $, which also matches just before a trailing newline, so
  "data:font/woff2;base64,AAAA\n" passed even though the frontend JS
  pattern rejects it. Use re.fullmatch for parity.

Adds covering tests for both.

* Unsloth: preview fonts in their own typeface and slim the color pills

- Every font dropdown entry, the default item, and the closed trigger
  render in the font they name, falling back to the UI stack for
  families the browser cannot resolve
- Color swatch pills drop from 36px to 28px so they sit closer to the
  row label height

* Unsloth: drop the font row and theme section descriptions

The labels carry the meaning on their own; the mode switching note in
particular read long and confusing.

* Unsloth: let the chat greeting follow the heading font setting

The greeting stays on Hellix by default but adopts a chosen heading
font through a --custom-heading-font variable the applier sets only
while an override exists, so the thread root's sans reset for chat
prose no longer hides the user's pick from the greeting.

* Unsloth: divide the theme section clusters and align the color pill height

Separators return between colors and fonts and between fonts and
contrast, and the color pills share the 32px height of the font
dropdown triggers.

* Unsloth: color pills at half the dropdown width

Fixed w-24 against the w-48 font triggers, with tighter padding so the
hex value still fits.

* Studio: update dep-removal test after next-themes was replaced

The frontend no longer declares next-themes or imports it in src (it was
replaced by the custom theme store and boot script), so the checker now
reports its removal as a safe no-op. The C1 and C8 fixtures in
test_frontend_dep_removal.py still asserted next-themes was a used
dependency, which fails the studio frontend CI dependency-removal safety
check. Update C1 to expect a no-op PASS and drop next-themes from the C8
expected failures so the suite matches the checker's correct output.

* Studio: remove unused ageLabel and exportCollectionJsonl helpers

* Studio: fix blocked-storage theme desync, search jump race, font validation

- theme-store.ts: keep an in-memory currentTheme/currentPalette so a selected
  value survives when localStorage is blocked (private browsing). The snapshots
  previously re-read empty storage and reverted React state to the default while
  the DOM already changed. The matchMedia handler no longer re-reads storage, so
  it cannot clobber the in-memory choice; cross-tab storage events still adopt.
- settings-dialog.tsx: the search jump waited a single fixed 60ms for the
  deferred tab panel to render, then silently missed under render lag. Retry
  across animation frames until the target row exists, then scroll and flash.
- settings.py: apply the font-name character check to the four selected-font
  fields (uiFont/headingFont/chatFont/codeFont), and forbid backslash, comma,
  slash and control characters so a name cannot escape the quoted CSS
  font-family or smuggle extra fallbacks. Adds covering tests.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix appearance customization edge cases for PR #7077

- Reset all local preferences now also clears palette and appearance customization
- Number input wrapper keeps full width so fields fill their flex/grid cell, and the stepper stays pinned to the field edge
- Number stepper snaps to the min anchored step grid like the native spinner instead of leaving a step-invalid value
- Code font now applies to chat code fences and inline code via a dedicated token
- Reduce motion (on/off) is honored by onboarding/tour confetti and the theme toggle view transition
- Re-importing a font under the same name with new bytes now swaps the FontFace
- Keep local customization when a synced record predates the customization field, and re-push it

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Align client font name sanitization with server validation for PR #7077

sanitizeFont now strips the same characters the backend _FONT_NAME_FORBIDDEN
rejects (backslash, slash, comma, backtick) plus control chars, so a locally
chosen font name can no longer pass the client but fail the personalization PUT
and silently stall appearance sync.

* Address follow-up review items for PR #7077

- Number input wrapper carries React Flow interaction classes (nodrag/nopan/nowheel) so clicking the stepper arrows increments instead of dragging the node
- Preserve local palette and greeting-sloth toggle when the synced record predates those fields, and re-push them, mirroring the customization handling (new paletteSaved and greetingSlothSaved response flags)
- Add settings-search scroll targets (data-settings-label) for the Profile title, description, display name, nickname, and avatar shape rows

* Preserve absent personalization fields on PUT for PR #7077

A stale client that omits palette or customization previously had those
defaults materialized by model_dump() and persisted, which flipped
paletteSaved/customizationSaved to true and defeated the legacy detection.
The PUT now dumps only the request's set fields and merges them onto the
stored record, so omitted fields keep whatever was already stored.

* Persist theme and palette via a fixed allow-list for PR #7077

The theme/palette values reach setTheme/setPalette from the authenticated
personalization sync, which made the CodeQL clear-text-storage query treat
writing them to localStorage as storing sensitive data. Store a re-derived
literal from a constant map instead, so a plain UI preference is not tracked
as sensitive; behavior is unchanged.

* Harden imported-font handling for PR #7077

- syncImportedFonts: a rejected FontFace.load() only clears the registry entry
  if it still points at that face, so a same-name re-import while the old load
  was pending is no longer untracked/leaked.
- Cap imported-font names to the backend length (100) so an over-long name can
  no longer pass the client but fail the personalization PUT and stall sync.
- Add a backend test that a stale PUT preserves an existing stored palette and
  customization (not just that absent fields stay absent).

* Return the merged personalization record from PUT

The PUT /personalization handler returned the request payload, which
Pydantic had already filled with defaults for any field the client
omitted. A partial or stale write (for example a client sending only
theme) therefore got back a response that contradicted both storage and
the next GET: preserved fields like palette and the custom font showed
their defaults instead of the stored values.

Return model_validate(merged) so the response mirrors what was stored.
The stored record is still the full merged dict, so legacy fields the
model does not know about are preserved as before.

* Fix small UI and keyboard-focus defects in appearance settings

- Settings search now scrolls to the result within its destination tab
  instead of a same-named row in the previously rendered deferred tab
  (for example "Storage" and "Models folder" appear in both General and
  Resources).
- The reduce-motion segmented control honors its own Off/On/System choice
  by reading useReducedMotionConfig instead of the OS-only useReducedMotion.
- The color picker saturation/value area is operable by keyboard, so the
  role="slider" surface responds to the arrow keys it advertises.
- Profile avatars and palette cards show a visible keyboard focus ring
  again.
- Guard the persisted appearance-customization write so a blocked or full
  localStorage does not throw out of a store action, matching the theme
  store.
- Import the appearance store symbols from the settings feature barrel.

* Tighten appearance fix comments

---------

Co-authored-by: shimmyshimmer <shimmyshimmer@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-07-14 05:12:08 -07:00

1569 lines
45 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Edge-case suite for scripts/check_frontend_dep_removal.py.
Each case patches a copy of package.json to remove/move a dependency,
runs the checker against the real lockfile, and asserts the verdict.
Run: `python tests/studio/test_frontend_dep_removal.py` (exit 0 iff all pass).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
HEAD_PKG = REPO / "studio/frontend/package.json"
HEAD_LOCK = REPO / "studio/frontend/package-lock.json"
SCRIPT = REPO / "scripts/check_frontend_dep_removal.py"
@dataclass
class Case:
id: str
desc: str
remove: list[str]
expected_status: str # "PASS" | "FAIL"
expected_failures: list[str]
move_to_dev: list[str] | None = None # rare: deps moved, not removed
CASES: list[Case] = [
Case(
"C1",
"removing next-themes is a no-op: replaced by the custom theme store, "
"no longer a declared dep or src import",
["next-themes"],
"PASS",
[],
),
Case(
"C2",
"removing @xyflow/react breaks recipe-studio src imports "
"(no other declared dep pulls @xyflow/react)",
["@xyflow/react"],
"FAIL",
["@xyflow/react"],
),
Case(
"C3",
"removing katex is safe: streamdown/math, mermaid, "
"rehype-katex all keep it at top level",
["katex"],
"PASS",
[],
),
Case("C4", "removing clsx is safe: streamdown keeps it", ["clsx"], "PASS", []),
Case(
"C5",
"removing react is safe: peer of countless packages",
["react"],
"PASS",
[],
),
Case(
"C6",
"removing @radix-ui/react-slot is safe: pulled by "
"radix-ui umbrella + @assistant-ui/react",
["@radix-ui/react-slot"],
"PASS",
[],
),
Case(
"C7",
"removing zustand is safe: @assistant-ui/react keeps "
"top-level zustand@5.x (nested xyflow 4.x is irrelevant "
"to src imports)",
["zustand"],
"PASS",
[],
),
Case(
"C8",
"multi-remove with mixed safety: next-themes is now a no-op (removed), "
"@huggingface/hub + dexie still unsafe",
["next-themes", "@huggingface/hub", "dexie"],
"FAIL",
["@huggingface/hub", "dexie"],
),
Case(
"C9",
"removing @huggingface/hub breaks 5+ src imports",
["@huggingface/hub"],
"FAIL",
["@huggingface/hub"],
),
Case(
"C10",
"removing tailwind-merge is safe: streamdown keeps it",
["tailwind-merge"],
"PASS",
[],
),
Case(
"C11",
"removing a non-existent name is a no-op",
["__never_existed_in_pkg__"],
"PASS",
[],
),
Case(
"C12",
"moving @hugeicons/react from deps to devDeps is NOT a removal (still declared)",
[],
"PASS",
[],
move_to_dev = ["@hugeicons/react"],
),
Case(
"C13",
"removing @huggingface/hub AND @xyflow/react together: both "
"are root-only deps with no other parents, so both should FAIL",
["@huggingface/hub", "@xyflow/react"],
"FAIL",
["@huggingface/hub", "@xyflow/react"],
),
Case(
"C14",
"removing dexie breaks src imports (no other declared dep needs it)",
["dexie"],
"FAIL",
["dexie"],
),
Case(
"C15",
"removing motion (used in 20+ src imports including "
"framer-motion-style animations); no transitive parent",
["motion"],
"FAIL",
["motion"],
),
Case(
"C16",
"removing canvas-confetti (imported in confetti.tsx); no transitive parent",
["canvas-confetti"],
"FAIL",
["canvas-confetti"],
),
Case(
"C17",
"removing recharts (imported in chart.tsx); no transitive parent",
["recharts"],
"FAIL",
["recharts"],
),
Case(
"C18",
"removing js-yaml is safe: @eslint/eslintrc keeps it "
"(triggers @types/js-yaml orphan warning, non-fatal)",
["js-yaml"],
"PASS",
[],
),
Case(
"C19",
"removing node-forge (imported in providers-api.ts); no transitive parent",
["node-forge"],
"FAIL",
["node-forge"],
),
Case(
"C20",
"removing @tauri-apps/api is safe: all 5 @tauri-apps plugins declare it as a direct dep",
["@tauri-apps/api"],
"PASS",
[],
),
Case(
"C21",
"removing mammoth (imported in runtime-provider.tsx); no transitive parent",
["mammoth"],
"FAIL",
["mammoth"],
),
Case(
"C22",
"removing unpdf (imported in runtime-provider.tsx); no transitive parent",
["unpdf"],
"FAIL",
["unpdf"],
),
Case(
"C23",
"removing remark-gfm is safe: streamdown declares it as a direct dep",
["remark-gfm"],
"PASS",
[],
),
Case(
"C24",
"removing date-fns is safe: react-day-picker and "
"@base-ui/react both declare it as a direct dep",
["date-fns"],
"PASS",
[],
),
Case(
"C25",
"removing vite is safe: @vitejs/plugin-react and @tailwindcss/vite "
"keep it via peer (bin still resolves)",
["vite"],
"PASS",
[],
),
Case(
"C26",
"removing typescript is safe: 11 transitive @typescript-eslint/* "
"parents keep tsc bin alive",
["typescript"],
"PASS",
[],
),
Case(
"C27",
"removing eslint is safe: typescript-eslint and eslint-plugin-* "
"peers keep eslint bin alive",
["eslint"],
"PASS",
[],
),
Case(
"C28",
"removing @biomejs/biome breaks scripts.biome:check / biome:fix "
"(no transitive parents, biome bin orphans)",
["@biomejs/biome"],
"FAIL",
["@biomejs/biome"],
),
Case(
"C29",
"removing both @biomejs/biome AND @vitejs/plugin-react together: "
"biome dies outright; vite loses one of its two retained peers "
"but @tailwindcss/vite still keeps it",
["@biomejs/biome", "@vitejs/plugin-react"],
"FAIL",
["@biomejs/biome", "@vitejs/plugin-react"],
),
]
def synth_head(head_pkg: dict, case: Case) -> dict:
out = json.loads(json.dumps(head_pkg))
for name in case.remove:
for field in (
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
):
(out.get(field) or {}).pop(name, None)
if case.move_to_dev:
for name in case.move_to_dev:
v = (out.get("dependencies") or {}).pop(name, None)
if v is not None:
out.setdefault("devDependencies", {})[name] = v
return out
def run_case(case: Case, head_pkg: dict) -> tuple[bool, str]:
synth = synth_head(head_pkg, case)
with tempfile.NamedTemporaryFile("w", suffix = ".json", delete = False) as f:
json.dump(synth, f, indent = 2)
synth_path = f.name
try:
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--base-pkg",
str(HEAD_PKG),
"--head-pkg",
synth_path,
"--head-lock",
str(HEAD_LOCK),
],
capture_output = True,
text = True,
)
finally:
os.unlink(synth_path)
actual_status = {0: "PASS", 1: "FAIL"}.get(proc.returncode, f"RC{proc.returncode}")
failure_pkgs: list[str] = []
in_summary = False
for line in proc.stdout.splitlines():
if "FAIL:" in line and "removed package" in line:
in_summary = True
continue
if in_summary and line.strip().startswith("- "):
failure_pkgs.append(line.strip()[2:])
ok = actual_status == case.expected_status and set(failure_pkgs) == set(case.expected_failures)
return ok, (
f"expected: status={case.expected_status} fails={sorted(case.expected_failures)}\n"
f"actual: status={actual_status} fails={sorted(failure_pkgs)}\n"
f"--- stdout (first 30 lines) ---\n" + "\n".join(proc.stdout.splitlines()[:30])
)
# Classifier unit tests: feed snippets into classify(), assert the kind.
# Covers sneaky import shapes used to obscure a real usage.
# Import classify() by file path so this test needs no installed package.
import importlib.util as _ilu
_spec = _ilu.spec_from_file_location("_dep_check", str(SCRIPT))
_dep_check = _ilu.module_from_spec(_spec)
sys.modules["_dep_check"] = _dep_check # required so @dataclass can resolve annotations
_spec.loader.exec_module(_dep_check)
classify = _dep_check.classify
_next_real_bin = _dep_check._next_real_bin
scripts_bin_refs = _dep_check.scripts_bin_refs
@dataclass
class ClassifyCase:
id: str
desc: str
pkg: str
file: str
content: str
expected_kind: str | None # None means "no detection"
CLASSIFY_CASES: list[ClassifyCase] = [
# Bog-standard shapes
ClassifyCase(
"U01",
"single-line static import",
"next-themes",
"src/x.tsx",
'import { ThemeProvider } from "next-themes";',
"static_import",
),
ClassifyCase(
"U02",
"side-effect import",
"katex",
"src/x.tsx",
'import "katex/dist/katex.min.css";',
"side_effect_import",
),
ClassifyCase(
"U03",
"dynamic import",
"@tauri-apps/api",
"src/x.tsx",
'const { x } = await import("@tauri-apps/api/window");',
"dynamic_import",
),
ClassifyCase(
"U04",
"require()",
"lodash",
"src/x.js",
'const _ = require("lodash");',
"require",
),
ClassifyCase(
"U05",
"CSS @import",
"tailwindcss",
"src/x.css",
'@import "tailwindcss";',
"css_import",
),
# Sneaky shapes
ClassifyCase(
"U06",
"multi-line static import",
"next-themes",
"src/x.tsx",
'import {\n ThemeProvider,\n useTheme,\n} from "next-themes";',
"static_import",
),
ClassifyCase(
"U07",
"import type",
"@huggingface/hub",
"src/x.ts",
'import type { PipelineType } from "@huggingface/hub";',
"static_import",
),
ClassifyCase(
"U08",
"export * from re-export",
"@some-org/secrets",
"src/x.ts",
'export * from "@some-org/secrets";',
"re_export",
),
ClassifyCase(
"U09",
"export { x } from re-export",
"lodash-es",
"src/x.ts",
'export { foo, bar } from "lodash-es";',
"re_export",
),
ClassifyCase(
"U10",
"export type ... from re-export",
"@huggingface/hub",
"src/x.ts",
'export type { Foo } from "@huggingface/hub";',
"re_export",
),
ClassifyCase(
"U11",
"multi-line export from re-export",
"@some/pkg",
"src/x.ts",
'export {\n thing,\n other,\n} from "@some/pkg";',
"re_export",
),
ClassifyCase(
"U12",
"JSDoc @import",
"react",
"src/x.ts",
'/** @type {import("react").FC} */\nconst Foo = () => null;',
"dynamic_import",
),
ClassifyCase(
"U13",
"template literal package path",
"@assistant-ui/react",
"src/x.tsx",
"const url = `@assistant-ui/react`;",
"template_literal",
),
ClassifyCase(
"U14",
"new URL import-meta",
"monaco-editor",
"src/x.ts",
'new URL("monaco-editor/esm/vs/editor/editor.worker", import.meta.url);',
"new_url",
),
ClassifyCase(
"U15",
"tsc triple-slash type ref",
"@types/some-pkg",
"src/x.ts",
'/// <reference types="@types/some-pkg" />',
"tsc_triple_slash",
),
ClassifyCase(
"U16",
"HTML script src",
"alpinejs",
"index.html",
'<script src="/node_modules/alpinejs/dist/cdn.min.js"></script>',
"html_script",
),
ClassifyCase(
"U17",
"HTML link href",
"alpinejs",
"index.html",
'<link rel="stylesheet" href="/node_modules/alpinejs/dist/style.css">',
"html_link",
),
ClassifyCase(
"U18",
"bare quoted string in tsconfig paths",
"@huggingface/hub",
"tsconfig.json",
'"paths": { "hf": ["@huggingface/hub/*"] }',
"string_literal",
),
ClassifyCase(
"U19",
"vite alias key",
"@dagrejs/dagre",
"vite.config.ts",
'"@dagrejs/dagre": path.resolve(__dirname, "./..."),',
"string_literal",
),
# False-positive guards (these should NOT detect)
ClassifyCase(
"U20",
"different package with shared prefix",
"foo",
"src/x.ts",
'import { x } from "foobar";',
None,
),
ClassifyCase(
"U21",
"package mentioned in plain comment text",
"react",
"src/x.ts",
"// We migrated from react-router to tanstack-router",
None,
),
ClassifyCase(
"U22",
"package name as a URL path tail is NOT detected "
"(boundary rule: pkg must be followed by quote or `/`)",
"react",
"src/x.ts",
'const docs = "https://example.com/react";',
None,
),
ClassifyCase(
"U23",
"package name in Python file (ignored, Python can never import npm packages)",
"playwright",
"tests/x.py",
'label: str = "playwright"',
None,
),
ClassifyCase(
"U24",
"exact-prefix collision: pkg 'lodash' and 'lodash-es'",
"lodash",
"src/x.ts",
'import _ from "lodash-es";',
None,
),
ClassifyCase(
"U25",
"scoped pkg substring collision",
"@radix-ui/react-label",
"src/x.ts",
'import x from "@radix-ui/react-label-extra";',
None,
),
ClassifyCase(
"U26",
"package only mentioned in a markdown link",
"react",
"README.md",
"See [react](https://react.dev).",
None,
),
ClassifyCase(
"U27",
"side-effect import with subpath",
"katex",
"src/x.css",
'@import "katex/dist/katex.min.css";',
"css_import",
),
ClassifyCase(
"U28",
"require.resolve",
"lodash",
"build/x.cjs",
'const path = require.resolve("lodash/fp");',
"require",
),
ClassifyCase(
"U29",
"TypeScript ambient `declare module`",
"@tanstack/react-router",
"src/app/router.tsx",
'declare module "@tanstack/react-router" {\n interface X {}\n}',
"string_literal",
),
ClassifyCase(
"U30",
"namespace import `import * as X from pkg`",
"@radix-ui/react-slot",
"src/x.tsx",
'import * as Slot from "@radix-ui/react-slot";',
"static_import",
),
ClassifyCase(
"U31",
"combined default + named import",
"react",
"src/x.tsx",
'import React, { useState } from "react";',
"static_import",
),
ClassifyCase(
"U32",
"default-as-named import alias",
"react",
"src/x.tsx",
'import { default as R } from "react";',
"static_import",
),
ClassifyCase(
"U33",
"re-export default",
"lodash",
"src/x.ts",
'export { default } from "lodash";',
"re_export",
),
ClassifyCase(
"U34",
"re-export default as alias",
"lodash",
"src/x.ts",
'export { default as _ } from "lodash";',
"re_export",
),
ClassifyCase(
"U35",
".then() dynamic import (no await)",
"@tauri-apps/api",
"src/x.ts",
'import("@tauri-apps/api/window").then(m => m.x());',
"dynamic_import",
),
ClassifyCase(
"U36",
"TypeScript import() in type position",
"react",
"src/x.ts",
'type C = import("react").ComponentType;',
"dynamic_import",
),
# File-type gating: JS classifiers must not fire on non-script files
# (.py/.md/.sh/.yml), whose JS-shaped strings are docs/test data, not usages.
ClassifyCase(
"U37",
"JS import snippet inside a Python fixture string is NOT a usage",
"next-themes",
"tests/studio/something.py",
"snippet = 'import x from \"next-themes\";'",
None,
),
ClassifyCase(
"U38",
"JS import snippet inside a Markdown code fence is NOT a usage",
"next-themes",
"docs/example.md",
'```ts\nimport x from "next-themes";\n```',
None,
),
ClassifyCase(
"U39",
"JS import inside a shell script is NOT classified as a JS usage",
"next-themes",
"scripts/build.sh",
'echo "import x from \\"next-themes\\";"',
None,
),
ClassifyCase(
"U40",
"JS import inside a YAML workflow is NOT classified as a JS usage",
"next-themes",
".github/workflows/x.yml",
"run: echo 'import x from \"next-themes\";'",
None,
),
# HTML script/link must respect package-name boundaries: a
# `/node_modules/foo-extra/...` reference does NOT use `foo`.
ClassifyCase(
"U41",
"HTML <script src=...> with similar-prefix package is NOT a match",
"foo",
"index.html",
'<script src="/node_modules/foo-extra/dist/index.js"></script>',
None,
),
ClassifyCase(
"U42",
"HTML <link href=...> with similar-prefix package is NOT a match",
"foo",
"index.html",
'<link rel="stylesheet" href="/node_modules/foo-extra/dist/style.css">',
None,
),
ClassifyCase(
"U43",
"HTML <script src=...> with exact package match IS a match",
"foo",
"index.html",
'<script src="/node_modules/foo/dist/index.js"></script>',
"html_script",
),
# CSS url() unquoted variant must classify the same as the quoted one.
ClassifyCase(
"U44",
"CSS url() unquoted bare package path",
"katex",
"src/x.css",
"src: url(katex/dist/fonts/font.woff2);",
"css_url",
),
ClassifyCase(
"U45",
"CSS url() quoted bare package path still works",
"katex",
"src/x.css",
'src: url("katex/dist/fonts/font.woff2");',
"css_url",
),
]
def run_classify_unit_tests() -> int:
passed = 0
for c in CLASSIFY_CASES:
actual = classify(c.pkg, c.file, c.content)
ok = actual == c.expected_kind
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {c.id}: {c.desc}")
if not ok:
print(f" pkg={c.pkg!r} file={c.file!r}")
print(f" content={c.content!r}")
print(f" expected={c.expected_kind!r}, actual={actual!r}")
if ok:
passed += 1
print()
print(f"{passed}/{len(CLASSIFY_CASES)} classify-unit cases pass")
return 0 if passed == len(CLASSIFY_CASES) else 1
# Adversarial end-to-end cases: drop a synthetic file into src/, run the
# checker, clean up. Catches regressions in the full grep+classify pipeline.
ADVERSARIAL_TMP_DIR = REPO / "studio/frontend/src/__dep_check_adversarial__"
@dataclass
class AdvCase:
id: str
desc: str
filename: str
content: str
target_pkg: str
expected_status: str
expected_failures: list[str]
ADV_CASES: list[AdvCase] = [
AdvCase(
"A01",
"multi-line import of removed pkg should FAIL",
"adv01.ts",
'import {\n foo,\n bar,\n} from "__adv_only_pkg_a__";\n',
"__adv_only_pkg_a__",
"FAIL",
["__adv_only_pkg_a__"],
),
AdvCase(
"A02",
"export * from removed pkg should FAIL",
"adv02.ts",
'export * from "__adv_only_pkg_b__";\n',
"__adv_only_pkg_b__",
"FAIL",
["__adv_only_pkg_b__"],
),
AdvCase(
"A03",
"export { x } from removed pkg should FAIL",
"adv03.ts",
'export { foo, bar } from "__adv_only_pkg_c__";\n',
"__adv_only_pkg_c__",
"FAIL",
["__adv_only_pkg_c__"],
),
AdvCase(
"A04",
"export type ... from removed pkg should FAIL",
"adv04.ts",
'export type { Foo } from "__adv_only_pkg_d__";\n',
"__adv_only_pkg_d__",
"FAIL",
["__adv_only_pkg_d__"],
),
AdvCase(
"A05",
"package with similar prefix should NOT trigger FAIL",
"adv05.ts",
# Imports the *_extra* name; removing the shorter name is safe (zero usage).
'import x from "__adv_only_pkg_e_extra__";\n',
"__adv_only_pkg_e__",
"PASS",
[],
),
AdvCase(
"A06",
"dynamic import of removed pkg should FAIL",
"adv06.ts",
'const m = await import("__adv_only_pkg_f__");\n',
"__adv_only_pkg_f__",
"FAIL",
["__adv_only_pkg_f__"],
),
AdvCase(
"A07",
"new URL of removed pkg should FAIL",
"adv07.ts",
'const w = new URL("__adv_only_pkg_g__/worker.js", import.meta.url);\n',
"__adv_only_pkg_g__",
"FAIL",
["__adv_only_pkg_g__"],
),
AdvCase(
"A08",
"string-concat dynamic import is unanalyzable (PASS)",
"adv08.ts",
'const m = await import("__adv_only_" + "pkg_h__");\n',
"__adv_only_pkg_h__",
"PASS",
[],
),
AdvCase(
"A09",
"package referenced only inside a JS comment "
"is conservatively flagged via the string_literal fallback "
"(this is acceptable -- err on the side of caution)",
"adv09.ts",
'// TODO: import x from "__adv_only_pkg_i__"\n',
"__adv_only_pkg_i__",
"FAIL",
["__adv_only_pkg_i__"],
),
AdvCase(
"A10",
"package referenced only in a Python file should NOT trigger a JS FAIL",
"adv10.py",
'label = "__adv_only_pkg_j__"\n',
"__adv_only_pkg_j__",
"PASS",
[],
),
AdvCase(
"A11",
"package mentioned in a markdown doc file is ignored by JS-like-only string_literal",
"adv11.md",
"See [docs](https://example.com/__adv_only_pkg_k__).\n",
"__adv_only_pkg_k__",
"PASS",
[],
),
AdvCase(
"A12",
"JSDoc @import of removed pkg should FAIL",
"adv12.ts",
'/** @type {import("__adv_only_pkg_l__").Foo} */\n' "const x = null;\n",
"__adv_only_pkg_l__",
"FAIL",
["__adv_only_pkg_l__"],
),
# Prettier puts `import` ~22 lines from the `from "pkg"` clause; the old
# ±4-line classify fallback missed it. Exercises the widened (±25) window.
AdvCase(
"A13",
"Prettier-style 22-identifier multi-line import should FAIL "
"(exercises the widened multi-line classify window)",
"adv13.ts",
"import {\n"
+ "".join(f" ident_{i:02d},\n" for i in range(22))
+ '} from "__adv_only_pkg_m__";\n',
"__adv_only_pkg_m__",
"FAIL",
["__adv_only_pkg_m__"],
),
]
# package.json field-reference cases: simulate prettier/eslintConfig/overrides/
# peerDependenciesMeta etc., testing package_json_extra_refs() coverage.
@dataclass
class PkgFieldCase:
id: str
desc: str
field_patch: dict # extra fields to merge into synth_head package.json
target_pkg: str
expected_status: str
expected_failures: list[str]
PKG_FIELD_CASES: list[PkgFieldCase] = [
PkgFieldCase(
"P01",
"removing pkg referenced only in `prettier` string field",
{"prettier": "__pkg_prettier_config__"},
"__pkg_prettier_config__",
"FAIL",
["__pkg_prettier_config__"],
),
PkgFieldCase(
"P02",
"removing pkg referenced in `eslintConfig.extends` array",
{"eslintConfig": {"extends": ["__pkg_eslint_cfg__"]}},
"__pkg_eslint_cfg__",
"FAIL",
["__pkg_eslint_cfg__"],
),
PkgFieldCase(
"P03",
"removing pkg referenced in `stylelint.plugins`",
{"stylelint": {"plugins": ["__pkg_stylelint_plugin__"]}},
"__pkg_stylelint_plugin__",
"FAIL",
["__pkg_stylelint_plugin__"],
),
PkgFieldCase(
"P04",
"removing pkg referenced in `babel.presets`",
{"babel": {"presets": [["__pkg_babel_preset__", {"opt": 1}]]}},
"__pkg_babel_preset__",
"FAIL",
["__pkg_babel_preset__"],
),
PkgFieldCase(
"P05",
"removing pkg used as a key in `overrides`",
{"overrides": {"__pkg_overridden__": "^1.0.0"}},
"__pkg_overridden__",
"FAIL",
["__pkg_overridden__"],
),
PkgFieldCase(
"P06",
"removing pkg used as a key in `pnpm.overrides`",
{"pnpm": {"overrides": {"__pkg_pnpm_override__": "^1.0.0"}}},
"__pkg_pnpm_override__",
"FAIL",
["__pkg_pnpm_override__"],
),
PkgFieldCase(
"P07",
"removing pkg used as a key in `pnpm.patchedDependencies`",
{"pnpm": {"patchedDependencies": {"__pkg_patched__": "patches/x.patch"}}},
"__pkg_patched__",
"FAIL",
["__pkg_patched__"],
),
PkgFieldCase(
"P08",
"removing pkg used as a key in `peerDependenciesMeta`",
{"peerDependenciesMeta": {"__pkg_peer_meta__": {"optional": True}}},
"__pkg_peer_meta__",
"FAIL",
["__pkg_peer_meta__"],
),
PkgFieldCase(
"P09",
"removing pkg referenced in `jest.preset` string",
{"jest": {"preset": "__pkg_jest_preset__"}},
"__pkg_jest_preset__",
"FAIL",
["__pkg_jest_preset__"],
),
PkgFieldCase(
"P10",
"removing pkg referenced in `commitlint.extends`",
{"commitlint": {"extends": ["__pkg_commitlint__"]}},
"__pkg_commitlint__",
"FAIL",
["__pkg_commitlint__"],
),
PkgFieldCase(
"P11",
"removing pkg referenced in `renovate.extends`",
{"renovate": {"extends": ["__pkg_renovate__"]}},
"__pkg_renovate__",
"FAIL",
["__pkg_renovate__"],
),
PkgFieldCase(
"P12",
"removing pkg referenced in `remarkConfig.plugins`",
{"remarkConfig": {"plugins": ["__pkg_remark__"]}},
"__pkg_remark__",
"FAIL",
["__pkg_remark__"],
),
PkgFieldCase(
"P13",
"removing pkg with subpath ref in tool config (`pkg/config`)",
{"prettier": "__pkg_prettier_sub__/config"},
"__pkg_prettier_sub__",
"FAIL",
["__pkg_prettier_sub__"],
),
PkgFieldCase(
"P14",
"false-positive guard: similar-prefix package in tool config",
{"prettier": "__pkg_short_extra__/config"},
"__pkg_short__",
"PASS",
[],
),
PkgFieldCase(
"P15",
"false-positive guard: package-named string in `browserslist` "
"must NOT trigger (browserslist values are browser queries, "
"never package names)",
{"browserslist": ["last 2 versions", "__pkg_browserslist__"]},
"__pkg_browserslist__",
"PASS",
[],
),
PkgFieldCase(
"P16",
"false-positive guard: matching string in `keywords` field",
{"keywords": ["__pkg_keyword__", "foo"]},
"__pkg_keyword__",
"PASS",
[],
),
PkgFieldCase(
"P17",
"false-positive guard: matching string in `workspaces` (paths)",
{"workspaces": ["packages/__pkg_workspace_path__"]},
"__pkg_workspace_path__",
"PASS",
[],
),
PkgFieldCase(
"P18",
"false-positive guard: matching value in `files` field",
{"files": ["dist/__pkg_in_files__"]},
"__pkg_in_files__",
"PASS",
[],
),
PkgFieldCase(
"P19",
"false-positive guard: matching `packageManager` string",
{"packageManager": "__pkg_in_pm__@1.0.0"},
"__pkg_in_pm__",
"PASS",
[],
),
]
def run_pkg_field_cases() -> int:
head_pkg = json.loads(HEAD_PKG.read_text())
passed = 0
for pc in PKG_FIELD_CASES:
synth_head = json.loads(json.dumps(head_pkg))
# Apply the field patch (deep-merge isn't needed; we control the keys).
for k, v in pc.field_patch.items():
synth_head[k] = v
# Base declares the target; head drops it from deps but references it
# via the extra field.
synth_base = json.loads(json.dumps(head_pkg))
synth_base.setdefault("dependencies", {})[pc.target_pkg] = "^1.0.0"
with tempfile.NamedTemporaryFile("w", suffix = ".json", delete = False) as f:
json.dump(synth_base, f, indent = 2)
base_path = f.name
with tempfile.NamedTemporaryFile("w", suffix = ".json", delete = False) as f:
json.dump(synth_head, f, indent = 2)
head_path = f.name
try:
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--base-pkg",
base_path,
"--head-pkg",
head_path,
"--head-lock",
str(HEAD_LOCK),
],
capture_output = True,
text = True,
cwd = str(REPO),
)
finally:
os.unlink(base_path)
os.unlink(head_path)
actual_status = {0: "PASS", 1: "FAIL"}.get(proc.returncode, f"RC{proc.returncode}")
fails: list[str] = []
in_summary = False
for line in proc.stdout.splitlines():
if "FAIL:" in line and "removed package" in line:
in_summary = True
continue
if in_summary and line.strip().startswith("- "):
fails.append(line.strip()[2:])
# Both status and failure set must match.
ok = actual_status == pc.expected_status and set(fails) == set(pc.expected_failures)
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {pc.id}: {pc.desc}")
if not ok:
print(f" expected: status={pc.expected_status} fails={pc.expected_failures}")
print(f" actual: status={actual_status} fails={fails}")
for ln in proc.stdout.splitlines()[:25]:
print(f" {ln}")
if ok:
passed += 1
print()
print(f"{passed}/{len(PKG_FIELD_CASES)} package.json-field cases pass")
return 0 if passed == len(PKG_FIELD_CASES) else 1
def run_adversarial_cases() -> int:
ADVERSARIAL_TMP_DIR.mkdir(parents = True, exist_ok = True)
head_pkg = json.loads(HEAD_PKG.read_text())
passed = 0
for ac in ADV_CASES:
# Drop the synthetic file.
fpath = ADVERSARIAL_TMP_DIR / ac.filename
try:
fpath.write_text(ac.content)
# Base adds the target pkg; real head lacks it, so the script
# treats it as removed and scans the repo (now with our file).
synth_base = json.loads(json.dumps(head_pkg))
synth_base.setdefault("dependencies", {})[ac.target_pkg] = "^1.0.0"
with tempfile.NamedTemporaryFile("w", suffix = ".json", delete = False) as f:
json.dump(synth_base, f, indent = 2)
base_path = f.name
try:
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--base-pkg",
base_path,
"--head-pkg",
str(HEAD_PKG),
"--head-lock",
str(HEAD_LOCK),
],
capture_output = True,
text = True,
cwd = str(REPO),
)
finally:
os.unlink(base_path)
actual_status = {0: "PASS", 1: "FAIL"}.get(proc.returncode, f"RC{proc.returncode}")
fails = []
in_summary = False
for line in proc.stdout.splitlines():
if "FAIL:" in line and "removed package" in line:
in_summary = True
continue
if in_summary and line.strip().startswith("- "):
fails.append(line.strip()[2:])
ok = actual_status == ac.expected_status and set(fails) == set(ac.expected_failures)
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {ac.id}: {ac.desc}")
if not ok:
print(f" expected: status={ac.expected_status} fails={ac.expected_failures}")
print(f" actual: status={actual_status} fails={fails}")
for ln in proc.stdout.splitlines()[:20]:
print(f" {ln}")
if ok:
passed += 1
finally:
try:
fpath.unlink()
except FileNotFoundError:
pass
# Clean up the directory.
try:
ADVERSARIAL_TMP_DIR.rmdir()
except OSError:
pass
print()
print(f"{passed}/{len(ADV_CASES)} adversarial cases pass")
return 0 if passed == len(ADV_CASES) else 1
# Dead-dep enumeration cases
@dataclass
class EnumCase:
id: str
desc: str
add_deps: dict[str, str]
add_dev_deps: dict[str, str]
field_patch: dict
extra_file: tuple[str, str] | None # (relative_path, content) or None
expected_unused: set[str]
expected_used: set[str]
expected_orphan_types: set[str]
ENUM_CASES: list[EnumCase] = [
EnumCase(
"E01",
"fake dep with no usage anywhere is flagged unused",
{"__enum_fake_unused_pkg__": "^1.0.0"},
{},
{},
None,
{"__enum_fake_unused_pkg__"},
set(),
set(),
),
EnumCase(
"E02",
"fake dep referenced via vite.config-style import is flagged used "
"(uses a real adversarial file as the import site)",
{"__enum_used_via_src__": "^1.0.0"},
{},
{},
(
"src/__dep_check_adversarial__/enum_e02.ts",
'import x from "__enum_used_via_src__";\n',
),
set(),
{"__enum_used_via_src__"},
set(),
),
EnumCase(
"E03",
"fake dep referenced only in package.json `overrides` is flagged used",
{"__enum_used_via_overrides__": "^1.0.0"},
{},
{"overrides": {"__enum_used_via_overrides__": "^1.0.0"}},
None,
set(),
{"__enum_used_via_overrides__"},
set(),
),
EnumCase(
"E04",
"@types/X where X is declared -> kept (NOT orphan)",
{"__enum_real_pkg__": "^1.0.0"},
{"@types/__enum_real_pkg__": "^1.0.0"},
{},
(
"src/__dep_check_adversarial__/enum_e04.ts",
'import x from "__enum_real_pkg__";\n',
),
set(),
{"__enum_real_pkg__"},
set(),
),
EnumCase(
"E05",
"@types/X where X is NOT declared anywhere -> orphan",
{},
{"@types/__enum_orphan_pkg__": "^1.0.0"},
{},
None,
set(),
set(),
{"@types/__enum_orphan_pkg__"},
),
]
def run_enum_cases() -> int:
head_pkg = json.loads(HEAD_PKG.read_text())
passed = 0
ADVERSARIAL_TMP_DIR.mkdir(parents = True, exist_ok = True)
for ec in ENUM_CASES:
synth_head = json.loads(json.dumps(head_pkg))
synth_head.setdefault("dependencies", {}).update(ec.add_deps)
synth_head.setdefault("devDependencies", {}).update(ec.add_dev_deps)
for k, v in ec.field_patch.items():
synth_head[k] = v
# Drop any temp source file if needed.
fpath = None
if ec.extra_file:
rel, content = ec.extra_file
fpath = REPO / rel
fpath.parent.mkdir(parents = True, exist_ok = True)
fpath.write_text(content)
with tempfile.NamedTemporaryFile("w", suffix = ".json", delete = False) as f:
json.dump(synth_head, f, indent = 2)
head_path = f.name
try:
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--base-pkg",
str(HEAD_PKG),
"--head-pkg",
head_path,
"--head-lock",
str(HEAD_LOCK),
"--enumerate-dead",
],
capture_output = True,
text = True,
cwd = str(REPO),
)
finally:
os.unlink(head_path)
if fpath:
try:
fpath.unlink()
except FileNotFoundError:
pass
# Parse the dead-dep enumeration output.
unused: set[str] = set()
orphans: set[str] = set()
in_unused = False
in_orphan = False
for line in proc.stdout.splitlines():
s = line.strip()
if s.startswith("unused ("):
in_unused = True
in_orphan = False
continue
if s.startswith("type_pkg_orphan ("):
in_unused = False
in_orphan = True
continue
if s.startswith("used:") or s.startswith("type_pkg_kept:"):
in_unused = in_orphan = False
continue
if s.startswith("- "):
if in_unused:
unused.add(s[2:])
elif in_orphan:
orphans.add(s[2:])
unused_ok = ec.expected_unused.issubset(unused) and (
not ec.expected_used or not (ec.expected_used & unused)
)
orphan_ok = ec.expected_orphan_types.issubset(orphans)
ok = unused_ok and orphan_ok
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {ec.id}: {ec.desc}")
if not ok:
print(f" expected unused superset: {sorted(ec.expected_unused)}")
print(f" expected used NOT in unused: {sorted(ec.expected_used)}")
print(f" expected orphans superset: {sorted(ec.expected_orphan_types)}")
print(f" actual unused: {sorted(unused)}")
print(f" actual orphans: {sorted(orphans)}")
for ln in proc.stdout.splitlines()[:30]:
print(f" {ln}")
if ok:
passed += 1
# Cleanup tmp dir if empty.
try:
ADVERSARIAL_TMP_DIR.rmdir()
except OSError:
pass
print()
print(f"{passed}/{len(ENUM_CASES)} enumeration cases pass")
return 0 if passed == len(ENUM_CASES) else 1
# Script-wrapper cases: scripts_bin_refs / _next_real_bin must credit the real
# bin (`biome` -> @biomejs/biome), not the wrapper. The old "first non-env
# token" heuristic missed cross-env / dotenv / etc.
@dataclass
class WrapperCase:
id: str
desc: str
raw_cmd: str
expected_bin: str | None # None means "no real bin (e.g. unwrappable)"
WRAPPER_CASES: list[WrapperCase] = [
WrapperCase(
"W01",
"cross-env wraps the real bin",
"cross-env CI=1 biome check .",
"biome",
),
WrapperCase(
"W02",
"cross-env with multiple env tokens after the wrapper",
"cross-env A=1 B=2 NODE_ENV=prod biome check",
"biome",
),
WrapperCase(
"W03",
"bare env-prefix run (no wrapper) still peels the env tokens",
"FOO=bar biome check",
"biome",
),
WrapperCase(
"W04",
"quoted env value with spaces (shlex preserves it as one word)",
'FOO="a b" biome check',
"biome",
),
WrapperCase(
"W05",
"npx + cross-env: runner peels, wrapper peels, real bin wins",
"npx cross-env CI=1 biome check",
"biome",
),
WrapperCase(
"W06",
"pnpm exec + cross-env",
"pnpm exec cross-env CI=1 biome check",
"biome",
),
WrapperCase(
"W07",
"dotenv with the `--` separator before the wrapped command",
"dotenv -- biome check",
"biome",
),
WrapperCase(
"W08",
"dotenv with a flag-arg pair and `--` separator",
"dotenv -e .env -- biome check",
"biome",
),
WrapperCase(
"W09",
"leading `./node_modules/.bin/` prefix is stripped",
"./node_modules/.bin/biome check",
"biome",
),
WrapperCase(
"W10",
"concurrently is NOT a script wrapper -- it dispatches by "
"script *name*, not bin, so the real bin is `concurrently` "
"itself (the wrapped script names are credited by their own "
"scripts entries, which scripts_bin_refs iterates separately)",
'concurrently "npm:dev" "npm:typecheck"',
"concurrently",
),
]
def run_wrapper_cases() -> int:
import shlex
passed = 0
for wc in WRAPPER_CASES:
try:
words = shlex.split(wc.raw_cmd, posix = True)
except ValueError:
words = wc.raw_cmd.split()
actual = _next_real_bin(words, 0)
ok = actual == wc.expected_bin
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {wc.id}: {wc.desc}")
if not ok:
print(f" raw_cmd={wc.raw_cmd!r}")
print(f" expected={wc.expected_bin!r}, actual={actual!r}")
if ok:
passed += 1
# End-to-end: feed scripts_bin_refs a head_pkg whose scripts use a wrapper
# and confirm the wrapped bin's owner is credited (the find_command_usage path).
int_total = 0
int_passed = 0
int_cases = [
(
"I01",
"cross-env wrapping `biome` credits @biomejs/biome",
{"lint": "cross-env CI=1 biome check"},
{"biome": "@biomejs/biome"},
"@biomejs/biome",
),
(
"I02",
"dotenv -- biome credits @biomejs/biome",
{"lint": "dotenv -- biome check"},
{"biome": "@biomejs/biome"},
"@biomejs/biome",
),
(
"I03",
"quoted env value before bin still credits the bin's owner",
{"lint": 'FOO="a b" biome check .'},
{"biome": "@biomejs/biome"},
"@biomejs/biome",
),
(
"I04",
"&& chain: both halves credit their owning packages",
{"build": "tsc -b && cross-env CI=1 biome check"},
{"tsc": "typescript", "biome": "@biomejs/biome"},
None, # checked via owning_pkgs below
),
]
for case_id, desc, scripts, bin_to_pkg, expect_owner in int_cases:
int_total += 1
refs = scripts_bin_refs({"scripts": scripts}, bin_to_pkg)
if case_id == "I04":
owners = set(refs.keys())
ok = owners == {"typescript", "@biomejs/biome"}
else:
ok = expect_owner in refs
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {case_id}: {desc}")
if not ok:
print(f" scripts={scripts!r} bin_to_pkg={bin_to_pkg!r}")
print(f" refs={refs!r}")
if ok:
int_passed += 1
total = len(WRAPPER_CASES) + int_total
print()
print(f"{passed + int_passed}/{total} wrapper-script cases pass")
return 0 if (passed == len(WRAPPER_CASES) and int_passed == int_total) else 1
def main() -> int:
head_pkg = json.loads(HEAD_PKG.read_text())
print(f"Running {len(CASES)} edge cases against {SCRIPT.relative_to(REPO)}")
print()
results: list[tuple[Case, bool, str]] = []
for c in CASES:
ok, detail = run_case(c, head_pkg)
results.append((c, ok, detail))
mark = "PASS" if ok else "FAIL"
print(f" [{mark}] {c.id}: {c.desc}")
if not ok:
for line in detail.splitlines():
print(f" {line}")
print()
passed = sum(1 for _, ok, _ in results if ok)
total = len(results)
print(f"{passed}/{total} edge cases pass")
print()
print(f"Running {len(CLASSIFY_CASES)} classify() unit cases")
print()
cls_rc = run_classify_unit_tests()
print()
print(f"Running {len(ADV_CASES)} adversarial end-to-end cases")
print()
adv_rc = run_adversarial_cases()
print()
print(f"Running {len(PKG_FIELD_CASES)} package.json-field cases")
print()
pkg_rc = run_pkg_field_cases()
print()
print(f"Running {len(ENUM_CASES)} dead-dep enumeration cases")
print()
enum_rc = run_enum_cases()
print()
print(
f"Running {len(WRAPPER_CASES)} script-wrapper cases "
"(_next_real_bin + scripts_bin_refs end-to-end)"
)
print()
wrap_rc = run_wrapper_cases()
if (
passed == total
and cls_rc == 0
and adv_rc == 0
and pkg_rc == 0
and enum_rc == 0
and wrap_rc == 0
):
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())