vis deps
Lint workspace dependency policies (workspace-protocol, banned-deps, redefine-root, workspace-versions, custom-types)
vis deps
Workspace-wide dependency policy linter. Reads every package.json in the workspace and reports drift, banned deps, and redefinitions against the root. --fix rewrites files in place.
vis depsRunning with no flags enables every check at once. Pass a specific flag to scope the run.
Usage
vis deps [options]Examples
# Run every enabled lint and exit non-zero on failures
vis deps
# Only check that internal deps use workspace:*
vis deps --workspace-protocol
# Auto-rewrite internal deps to use workspace:*
vis deps --workspace-protocol --fix
# Flag deps duplicated between root and child packages
vis deps --redefine-root
# Flag deps matching policy.bannedDeps in vis config
vis deps --banned-deps
# One-off ban: flag any package declaring left-pad or request
vis deps --ban left-pad --ban request
# Flag external deps declared at different versions across packages
vis deps --workspace-versions
# Rewrite drifting deps to the highest sibling version
vis deps --workspace-versions --fix
# One-off pin: flag any package declaring react at a different version
vis deps --pin react@18.2.0
# Rewrite drifting deps to catalog: when a catalog already pins them
vis deps --workspace-versions --resolve catalog --fix
# Suggest new catalog entries for deps ≥3 packages already agree on
vis deps --workspace-versions --resolve catalog --propose-min 3
# Flag drift in engines.{node,pnpm}, packageManager, volta.{node,pnpm,yarn}, devEngines
vis deps --custom-types
# Align all engines/packageManager/volta versions to the highest sibling
vis deps --custom-types --fix
# Emit findings as JSON for CI / editor integrations
vis deps --format jsonOptions
| Option | Default | Description |
|---|---|---|
--workspace-protocol | false | Lint that internal deps use the workspace: protocol |
--redefine-root | false | Lint that no child re-declares a dep already pinned in the workspace root |
--banned-deps | false | Lint deps against policy.bannedDeps in vis config |
--workspace-versions | false | Lint that all packages declare external deps at the same version |
--custom-types | false | Lint engines.{node,pnpm}, packageManager, volta.*, devEngines.* for drift across packages |
--empty-deps | false | Flag empty dependency blocks (dependencies: {}, devDependencies: {}, …) |
--root-private | false | Ensure the workspace root package.json sets "private": true |
--root-package-manager | false | Ensure the workspace root package.json declares a packageManager field |
--root-deps | false | Flag runtime dependencies on the private workspace root (move them to devDependencies) |
--missing-package-json | false | Flag workspace directories that lack a package.json |
--dead-workspace-patterns | false | Flag workspace patterns that match zero packages |
--types-in-deps | false | Flag @types/* declared in dependencies on a private package (should be devDependencies) |
--similar-deps | false | Flag version drift across related dep families (react+react-dom, @babel/*, @storybook/*, …) |
--dep | Restrict --workspace-versions/--custom-types to a single dep | |
--ban | Ban a dep name or glob for this run (repeatable). Auto-enables --banned-deps | |
--pin | Pin a dep to an exact specifier for this run, e.g. react@^18.2.0 (repeatable) | |
--resolve | Conflict resolution: highest, lowest, or catalog (default: highest) | |
--propose-min | Propose catalog entries for deps ≥N packages already agree on. Activates with --resolve catalog | |
--fix | false | Auto-fix violations in place (writes package.json files) |
--fix-specifier | Specifier used by --fix for workspace-protocol (default: workspace:*) | |
--format | human | Output format: human, json, or minimal |
--quiet | false | Suppress all output except errors |
Lint kinds
--workspace-protocol
Every internal dep — one whose name matches a workspace package — must use workspace:* (or workspace:^ / workspace:~ / a workspace-relative range). Catches the case where a package was extracted from the registry to the workspace but its consumers still pin the registry version.
--redefine-root
Flags every dep that appears in both the workspace root package.json and a child package's package.json. The root pin is canonical; redefining it in a child either masks the root version or silently drifts.
--banned-deps
Reads policy.bannedDeps (or --ban <name> repeatable) and flags every package that declares any of them. Glob support (legacy-*, **deprecated**).
Each rule is either a plain reason string or an object with { reason, replacement?, packages?, paths? }. Optional packages (globs over the declaring package's name) and paths (globs over the workspace-relative packageDir) narrow where the rule applies; with both set, either match is enough. Omit both to ban anywhere — the default. Exact-name keys still beat globs, but only among rules whose scope matches the candidate dep.
policy: {
bannedDeps: {
request: "deprecated; use undici",
moment: { reason: "huge bundle, frozen upstream", replacement: "date-fns" },
// Apply only inside shared libs.
react: { reason: "no react in shared libs", paths: ["packages/shared/**"] },
// Apply only to apps.
next: { reason: "apps only", packages: ["@app/*"] },
},
},--workspace-versions
Detects "the same dep declared at different versions across packages." Default policy is highest-semver-wins per dep across dependencies ∪ devDependencies ∪ peerDependencies. With --resolve catalog, drifting deps are rewritten to catalog: when a sibling catalog already pins them; with --propose-min N, new catalog entries are suggested for deps that ≥N packages already agree on.
--custom-types
Detects drift in version pins that live outside *Dependencies blocks. Five built-in customTypes:
| customType | Source |
|---|---|
engines | pkg.engines.{node,pnpm,yarn,npm,...} |
volta | pkg.volta.{node,pnpm,yarn} |
packageManager | pkg.packageManager — name@version or name@version+sha512.<hash> |
devEngines.runtime | pkg.devEngines.runtime (single object or {name, version}[]) |
devEngines.packageManager | pkg.devEngines.packageManager (same shape) |
Each (customType × depName) cluster is tracked independently — engines.node and volta.node do not cross-couple. Use a versionGroup once that lands if you need to enforce they agree.
--fix rewrites in place. The +sha512.<hash> integrity suffix on packageManager is dropped on bump — content-integrity hashes are tied to a specific tarball, not a version, so users must regenerate via Corepack:
vis deps --custom-types --fix
corepack use pnpm@10.32.1 # regenerate the +sha512 hashUser-defined customTypes (policy.customTypes.extraTypes)
When a tool stores version pins somewhere built-in customTypes don't reach (e.g. legacy pnpm.overrides, a private toolchain block, an in-house resolver field), declare it under policy.customTypes.extraTypes. Entries are layered on top of the built-ins — they never replace engines/volta/packageManager etc.
Pick a strategy matching how the version is encoded at the path:
| strategy | Shape at path | When to use |
|---|---|---|
versionsByName | { "<name>": "<version>", ... } | A block of name → version, like pnpm.overrides or engines. |
name@version | "<name>@<version>" (string) | Single-string slots like packageManager (pnpm@10.32.1). |
name~version | "<name>~<version>" (string) | Tilde-separated single-string slots (mirrors syncpack's tilde form). |
string | "<version>" (bare) | A bare version string. Requires depName so we know what to call it. |
export default {
policy: {
customTypes: {
extraTypes: [
// Legacy pnpm.overrides — versionsByName at pnpm.overrides.
{ name: "pnpmOverridesLegacy", path: "pnpm.overrides", strategy: "versionsByName" },
// Private toolchain pin: { runtime: "node@22.14.0" }
{ name: "myToolPin", path: "myTool.runtime", strategy: "name@version" },
// Tilde-separated pin: { runtime: "node~22.14.0" }
{ name: "myToolTildePin", path: "myTool.tildeRuntime", strategy: "name~version" },
// Bare version string: { minNode: "22.14.0" } — needs depName.
{ name: "minNode", path: "config.minNode", strategy: "string", depName: "node" },
],
},
},
};A name that collides with a built-in (engines, volta, packageManager, devEngines.runtime, devEngines.packageManager) is rejected with a non-zero exit — the built-in stays canonical. Missing/duplicate name, missing path, invalid strategy, or strategy: "string" without depName all fail validation up front before the workspace is scanned.
path always splits on . literally — there is no escape mechanism, so a single package.json key that itself contains a . (e.g. pkg["foo.bar"]) cannot be reached. Use a parent-then-child layout if you control the schema.
Overlap with vis doctor
vis doctorverifies the installed runtime matchesengines.node(machine-side: "is my Node binary the right version?").vis deps --custom-typesverifies all packages declare the sameengines.node(workspace-side: "do all my package.json files agree?").
Both ship; both useful. Run vis doctor on a developer machine to catch local mismatches; run vis deps --custom-types in CI to catch workspace drift.
Configuration
Set defaults under policy in your vis.config.ts (or vis.json):
export default {
policy: {
bannedDeps: ["left-pad", "request", "lodash.*"],
workspaceProtocol: {
// true | false | "prompt" — three-state autofix opt-out.
autofix: true,
},
workspaceVersions: {
autofix: true,
// Per-rule resolve override; CLI --resolve still wins.
resolve: "highest",
ignore: ["@types/node"],
},
customTypes: {
autofix: true,
ignore: ["bun"],
resolve: "highest",
// Optional — see "User-defined customTypes" above.
extraTypes: [{ name: "pnpmOverridesLegacy", path: "pnpm.overrides", strategy: "versionsByName" }],
},
},
};autofix: false denies the rewrite even when --fix is passed — the run still fails CI, but the file is left alone. Useful when a rule is inherently human-judgement (e.g., banning latest shouldn't be auto-fixed). autofix: "prompt" is reserved for future interactive UX; treats the run as report-only today.
Output formats
Human (default)
Color-coded sections per lint kind, grouped by ${customType} ${depName} for custom-types, and by ${depName} for workspace-versions.
JSON
Machine-readable output for CI / editor integrations:
{
"fixed": {
"catalogProposals": false,
"customTypes": false,
"workspaceProtocol": false,
"workspaceVersions": false
},
"customTypes": {
"issues": [
{
"customType": "engines",
"depName": "node",
"specifier": "20.0.0",
"fix": "22.14.0",
"packageName": "@my/b",
"packageJsonPath": "packages/b/package.json",
"canonicalSource": "@my/a"
}
],
"total": 1
}
}packageJsonPath is always workspace-relative for portability across CI runners.
Minimal
Tab-separated lines, one per issue, suitable for awk/cut pipelines:
custom-types packages/b/package.json engines node 20.0.0 → 22.14.0
workspace-versions packages/b/package.json dependencies react ^18.0.0 → ^18.2.0Exit codes
0— no issues, or all issues were auto-fixed1— issues found and not fixed (or autofix denied bypolicy.*.autofix: false)
CI typically runs vis deps with no --fix flag; the non-zero exit fails the build on drift. --fix is for local developer machines and pre-commit hooks.