Keeps every package of an Expo app on a written, verified set: the right version and specifier, in
the right place, old enough, not deprecated, reviewed, allowed to ship, and installed once. The facts
live in the app's contracts/dependency-policy.json; this skill seeds it, plans every change, and
proves the repo against it.
Rules that must hold
- One written policy.
contracts/dependency-policy.jsonholds the versions table (version, specifier, place, why), held-back majors, do-not-add, bans and the licence policy, seeded fromtemplates/contracts/dependency-policy.json(the canonical stack verified 2026-10-03, Expo SDK 57). A row changes in the same commit aspackage.jsonandpackage-lock.json. Why: npmlatestis the wrong major for several packages today (React Native 0.87, TypeScript 7, ESLint 10, Jest 30); one written truth stops upgrades from memory. - Install Expo-managed packages only with
npx expo install <pkg>@<row spec>, and keep the specifier it writes. Why: Expo tests one native set per SDK andexpo install --checkverifies it; without the spec,npx expo installwrites whatever Expo's online list names today, which can be a patch published that morning. A same-day Expo patch is aWARNwith its due date, never an install. - Pin every other package exactly, in its place (
save-exact=true): runtime code independencies, tools indevDependencies. Why: a patch of a native wrapper changes binaries; Expo's checks only test that the installed version is inside its range, so the exact pin is what makes each change a reviewed lockfile commit. - One copy of everything. Companions a row lets float are installed in the same command or
pinned in the root
overrides(all ten@typescript-eslint/*at thetypescript-eslintpin); onereact, onereact-native;npm ls <pkg>shows one version. Why: two@typescript-eslintplugins crash ESLint, and a native module can be compiled into a build only once. - Commit
package-lock.json, install clean trees withnpm ci(EAS Build does too), and never delete the lockfile, setpackage-lock=false, use--force/--legacy-peer-deps(on the command line, in.npmrcor in anNPM_CONFIG_*variable), or setEAS_NO_FROZEN_LOCKFILE. Why: the lockfile is what makes every machine and CI build the same app: EAS Build runsnpm ciand fails on a lockfile out of sync, whilenpm install(what developers run, and EAS Build underEAS_NO_FROZEN_LOCKFILE) ignores it underpackage-lock=false;ETARGETmeans too young,ERESOLVEmeans off the SDK set, andlegacy-peer-deps=truesilences that for every later install. .npmrcholdsmin-release-age=7(or a stricter number of days),engine-strict=true,save-exact=trueandstrict-allow-scripts=true; the rootpackage.jsonholdsenginesnpm >=11.17.0andnode >=22.18; nothing weakens them: no later line, nobefore=, noNPM_CONFIG_*variable ineas.jsonor CI, no~/.npmrcline the project file leaves open. A younger release enters only through a dated# exclude-block expires=YYYY-MM-DD reason=…block the developer agreed to, naming exact packages, expiring on the publish date + the release age, deleted (never extended) on that date. Why: a 7-day wait lets most malicious or broken releases be pulled first; npm keeps the last value of a key and reads command line, then environment, then project.npmrc, then~/.npmrc;engine-strictguards nothing withoutengines, and the npm of the default EAS images ignores the release age andallowScripts.- Review an install script before it runs, one package at a time. With
strict-allow-scripts=truenpm stops an install whose tree holds an unreviewed script before anything runs (ESTRICTALLOWSCRIPTS); without it npm 11.17 runs the script and only prints a notice afterwards. Read the script the plan prints, write its pinned key ("<pkg>@<x.y.z>": true) into the rootallowScripts, then install; a bump needs a new review. Never a name-only approval ("<pkg>": true,"<pkg>@*": true): npm runs every future version's script under it; a name-onlyfalse(deny every version, whatnpm deny-scriptswrites) is fine. npx andnpm execinside the repo follow.npmrcbut never the rootallowScripts: a tool the repo runs is a devDependency (npx then runs the reviewed local copy), EAS CLI (expo-doctor refuses it as a dependency) a global install with the policy flags, andallow-scriptsstays out of every.npmrcand variable. Why: an install script runs arbitrary code on every machine and CI runner that installs. - Never add a banned package: the supply-chain floor (compromised or deprecated packages), plus
the app's own bans and opt-in profiles (
offline-only,no-telemetry), all listed in the policy'sbanned. Why: the app's own gate reads only the policy, so the list is complete there. - No deprecated direct dependency without a written reason in its row. Why: deprecated code gets no fixes; a deliberate hold (ESLint 9, held back) stays visible instead of silent.
- Ship only allowed licences (by default MIT, ISC, Apache-2.0, BSD-2/3-Clause, 0BSD, OFL-1.1, CC0-1.0) or an approved exception with its exact licence and reason; audit the release bundle, not the lockfile, and read the native SDKs a native module brings by hand. Why: only shipped code carries obligations into the app, and the bundle audit sees JavaScript only: the CocoaPods and Gradle SDKs under a native module (an ads or crash SDK) ship too.
npx expo install --checkandnpx expo-doctorpass. Why: they see SDK drift and duplicate native modules thattscand Jest cannot.- Respect held-back majors until their written triggers; move Expo SDK majors only with the expo-sdk-upgrade skill. Why: each held-back major breaks a verified part of the stack.
- Install a package in the same change as the code that uses it. Why: an unused package ages unreviewed, and knip cannot see an unused native module once a shared package peers it.
- Stop and ask the developer before a package not in the policy, a dated exception, a licence
exception or a deprecation hold, bringing the facts
plan-dependency.mjs --onlineprints. - Read a library's versioned docs before coding against it, never memory, and re-verify a row
before trusting it when the policy's
verifieddate is more than a few weeks old.
When multi-package (npm workspaces), rules 3 and 4 also mean: a native module is never in a shared
package's dependencies or at the root (Expo's checks verify only an app's direct dependencies, and
only one copy of a native module can be compiled); a shared package lists the native modules its code
imports as "*" peers; every app that depends on it installs them with one specifier; the root
overrides pin each peer at the apps' version, plus react and react-native. A JavaScript library
the apps and a shared package both import is one row whose where lists both places.
Workflow
- Name the task: add, upgrade, remove, the monthly pass, a dated exception, or a failing dependency gate. A failing gate: go straight to "When a gate in this area fails" in references/supply-chain.md.
- No
contracts/dependency-policy.jsonyet: copytemplates/contracts/dependency-policy.json, delete the rows the app will never use, and read references/policy-contract.md. No.npmrc: copytemplates/dot-npmrcto.npmrc. - Read the package's row (and references/versions.md for why the seed says what it says), then the matching section of references/procedures.md.
- Plan:
node ${CLAUDE_SKILL_DIR}/scripts/plan-dependency.mjs <pkg>[@<version>] . --online(add--removeto remove; when multi-package,--peer-of <workspace>when a shared package imports it; offline, savenpm view <pkg>@<v> version time license scripts deprecated --jsonto a file and pass--npm-view <file>). AFAILline (banned,do-not-add,held-back,not-in-table,version-differs,too-young,deprecated,licence) means stop: fix the request, follow the upgrade procedure, or ask the developer. - Too young but needed: read references/release-age-policy.md,
get the developer's agreement, fill
assets/npmrc-exclude-block.txt(expiry = the publish date + the.npmrcmin-release-age; the plan'stoo-youngfix line prints it) and append it to.npmrcafter one blank line. - Review before installing: when the plan prints a
review firststep, read that script and write its pinned key into the rootallowScripts(references/supply-chain.md, "Install scripts"). An install that stops withESTRICTALLOWSCRIPTSran nothing: review each package it names the same way, then run the install again. A tool run through npx (EAS CLI, a scaffolder) follows "npx and one-off tools inside the repo" in the same reference instead. - Run the printed install steps exactly, in order (companions in the same command; when
multi-package, the peer and its root override first, then every app in the same change). Then
npm approve-scripts --allow-scripts-pendingmust printNo packages with unreviewed install scripts. - A config plugin: one entry in the
pluginslist ofapp.config.ts, with a comment naming the owning skill; never a second list, never an edit in the generatedios/orandroid/. A package whose plugin prebuild applies on its own (expo-notifications,expo-image-picker, ...) is listed even when its defaults are wanted (expo-prebuild-and-config-plugins,auto-plugin-listed). - Write the code that uses the package in the same change; a vendor SDK is imported only by its
adapter in
src/services/<concern>/. npx expo install --checkandnpx expo-doctor(inside each Expo app when multi-package, plusnpx expo-modules-autolinking verifyfor duplicate native modules).- Run
node ${CLAUDE_SKILL_DIR}/scripts/check-deps-policy.mjs .; fix everyFAILline (each names the file, the rule and the fix) and rerun untilRESULT: PASS. Thennpm run -s check:fastandnpm run -s check:unused. - A native or pre-1.0 package:
npx expo prebuild --clean, a development build on a named iOS simulator and a named Android emulator (oreas build --profile developmentwith a simulator profile:ios.simulator: true, an APK for Android; eas-build-and-submit owns the profiles); read what the config plugin changed (npx expo config --type introspect: iOS usage strings, plugin-written manifest entries), the Android permissions in the merged manifest or the built APK (library manifests and Play services add them at build time; the procedures reference says where), the new vendor SDKs inios/Podfile.lockand./gradlew :app:dependencies, and any binary the build downloads (podspecprepare_command, Gradle download tasks), for the privacy-and-store-declarations skill. Before the next release, also launch a preview or release build on both (R8, Hermes bytecode and release-only plugin branches never run in development). - Commit as in references/procedures.md ("What to record in the commit"), following examples/add-expo-haptics.md.
- A gate file under
scripts/deps/,scripts/audit/orscripts/clock/missing or damaged: restore it fromtemplates/scripts/(the path its first line names) and rerun its Jest tests. The rootpackage.jsonruns them through"check:deps": "node scripts/deps/check-deps.ts"(quality-gates calls it innpm run verify),"check:npm-config": "node scripts/deps/check-npm-config.ts"and"audit:licenses": "node scripts/audit/audit-licenses.ts"(afternpm run export:audit, the production release export with source maps).
Definition of done
- Every changed package is at its row's version and specifier, in its row's place, with its
companions installed in the same command or pinned in the root
overrides. -
npm ls <pkg>shows one version for every package the change touched, and forreact,react-nativeand@typescript-eslint/eslint-plugin. - The policy rows changed in the same commit as
package.jsonandpackage-lock.json. - Every new install script was read before it ran (the plan's
review firststep or anESTRICTALLOWSCRIPTSstop) and is approved by its pinned key (no name-onlytrue);npm approve-scripts --allow-scripts-pendingprintsNo packages with unreviewed install scripts. -
node scripts/deps/check-npm-config.tspasses on the machine and in CI: the config npm runs with (npm config get min-release-age strict-allow-scripts legacy-peer-deps package-lock before allow-scripts) is the policy, not anNPM_CONFIG_*variable or~/.npmrcline. - No committed npx call (package.json scripts, workflows,
lefthook.yml) installs a package the policy keeps out; EAS CLI runs from a pinned global install with the policy flags. -
npx expo install --checkandnpx expo-doctorpass (an Expo patch younger than 7 days is aWARNline with its due date fromscripts/deps/check-deps.ts, reported, not installed);npm run -s check:unusedandnpm run -s check:fastare green. - No banned package, no undated, expired, catch-all or extended exclude, no unaccepted
deprecation, no licence outside the policy without an approved exception (
npm run export:audit && npm run audit:licenseschecks more than 0 packages and finds every one). - A native change: development builds launched on a named iOS simulator and Android emulator, and a preview or release build on both before the next release.
-
node ${CLAUDE_SKILL_DIR}/scripts/check-deps-policy.mjs .printsRESULT: PASS(the only expected SKIP, while the app has no source folder yet:SKIP src [unused-dependency] not yet created (expo-project-bootstrap creates it))
Anti-patterns
- Installing from memory or from npm
latest(npm install react-native): the newest release is the wrong major. Take the policy row. - A bare
npx expo install <pkg>for an Expo-managed package: it writes what Expo's online list names today, possibly a patch published that morning. Pass the spec:npx expo install expo-constants@~57.0.21. npx expo install <pkg>for a package outside Expo's map: it runsnpm install --save <pkg>, which takes npmlatest(the newest release at least 7 days old, possibly the wrong major), not the policy row. Usenpm install <pkg>@<exact>.npx expo install X -- --save-dev(it lands independencies) ornpx expo install X --devfor a dev tool (Expo writes its~range intodevDependencies, which failspin-exact): usenpm install -D <pkg>@<row version>; Expo-map dev tools are pinned exactly inside Expo's range.- Installing a package before the code that uses it: knip or
unused-dependencyfails, and the package ages unreviewed. - Deleting
package-lock.json,--forceor--legacy-peer-depswhen an install fails, or writinglegacy-peer-deps=trueinto.npmrcto get pastERESOLVE: go back to the row instead. - Extending an expired exclude block, a glob exclude where an exact name would do, a catch-all
(
*,@*/*), or an exclude outside a dated block in any spelling: npm readsmin-release-age-exclude = xlike[]=x, and splitsa,binto two patterns. before=<date>in.npmrc: npm then dropsmin-release-agealtogether."env": { "NPM_CONFIG_LEGACY_PEER_DEPS": "true" }ineas.json(the usual ERESOLVE workaround on EAS) or anyNPM_CONFIG_*policy key in CI: a variable beats every line of.npmrc."EAS_NO_FROZEN_LOCKFILE": "1"when an EAS build fails withEUSAGE(npm cifoundpackage.jsonandpackage-lock.jsonout of sync): EAS then runsnpm installand resolves the drift on the worker, outside the reviewed lockfile. Runnpm installlocally and commit both.- Installing first and approving after,
npm approve-scripts --all,dangerously-allow-all-scripts,npm approve-scripts --no-allow-scripts-pin(it writes a name-only key), or approving without reading the script: withoutstrict-allow-scripts=truethe script has already run by the time the notice appears. npx eas-cli …inside the repo, oreas-cliindevDependencies: npx installs it under the project'sstrict-allow-scriptswithout the rootallowScripts, so it stops withESTRICTALLOWSCRIPTSon a fresh runner (a warm npx cache hides that), and expo-doctor fails the dependency. Install it globally:npm install -g --min-release-age=7 --ignore-scripts eas-cli@<x.y.z>after reading its scripts, then calleas(npx easis an unrelated package).- Getting an npx tool past
ESTRICTALLOWSCRIPTSthe wrong way: running it from outside the repo (no release age, every script runs unreviewed),--dangerously-allow-all-scripts,allow-scriptsin.npmrcor~/.npmrc, orNPM_CONFIG_ALLOW_SCRIPTS(npm then refusesnpm ciwithEALLOWSCRIPTS). Read the listed scripts and pass--ignore-scriptsor pinned--allow-scriptskeys on that one call, or make the tool a devDependency. npm audit fix(or--force): it rewrites pins outside the policy and can moveexpoto another SDK;npm explain <pkg>shows whether an advisory ships at all.- Installing RNTL without
test-renderer,jest-image-snapshotwithout its types, orexpo-routerwithout its peers: npm fills the gap with its newest release (test-renderer1.3.0, the SDK 58 line). Take the plan's companions. - Bumping
react,react-native, Reanimated, Skia or Gesture Handler by hand: they move only with an Expo SDK (expo-sdk-upgrade). - Installing an Expo patch the day
expo install --checkasks for it: it is younger than 7 days; it moves on its due date. - Hiding a banned or deprecated package behind an
overridesentry: remove the parent instead (npm explain <pkg>). - When multi-package: installing a native module in one app "to try it first", at the root, or in a
shared package's
dependencies: autolinking finds it (since SDK 54 it resolves recursively), butnpx expo install --checkandexpo-doctorverify only an app's direct dependencies, so that copy escapes the SDK set, and only one copy of a native module can be compiled. Install it in every app.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/policy-contract.md | Every key of contracts/dependency-policy.json, the ban floor and profiles, licences, what reads which key, editing rules |
Workflow step 2, and before editing the policy |
| references/versions.md | The canonical-stack rows the policy is seeded with, companions and overrides, held-back majors, do-not-add, toolchain (EAS Node and npm), the React Navigation appendix, re-verify commands | Workflow step 3, before any install or review |
| references/procedures.md | Add, add outside the policy, upgrade, remove, the monthly pass, after a native change (iOS and Android), command traps, the same-day Expo patch, EAS installs (npm ci, EAS_NO_FROZEN_LOCKFILE), the commit record, when multi-package |
Workflow steps 3 and 13 |
| references/release-age-policy.md | The .npmrc and engines, keys that must never be in .npmrc, verified npm 11.17 behaviour (npx and global installs included), how the plan measures age (--now, --today), the bootstrap block, dated exceptions, expiry, pnpm/Yarn/Bun notes |
On ETARGET, before an exception, on an expiry date |
| references/supply-chain.md | Bans, deprecation, install scripts (review before they run, strict-allow-scripts, the key each decision writes), npx and one-off tools inside the repo (EAS CLI, EAS workers), licences, Expo alignment, the gate files, fixes for failing gates |
Before adding a package or running a tool through npx; when a dependency gate fails |
| examples/add-expo-haptics.md | A complete add in a Tasks app: real plan output, install, approvals, checks, commit, report line | Workflow step 13, as the model |
templates/contracts/dependency-policy.json |
The seed policy: 90 canonical-stack rows, held-back majors, do-not-add, the supply-chain bans, the licence allowlist | Workflow step 2 (copy to contracts/) |
templates/dot-npmrc |
The .npmrc policy (copy to .npmrc) |
Workflow step 2 |
templates/scripts/ |
The app's gates: deps/check-deps.ts (+ dependency-policy, which finds the policy like the checkers do, release-age-excludes (the .npmrc policy), effective-npm-config (the config npm runs with), unpinned-approvals, engines-floor, banned-packages, workspace-lockstep, expo-patch-age, all with tests), deps/check-npm-config.ts (before an install: CI, EAS), audit/audit-licenses.ts (+ license-policy with test), clock/system-clock.ts (+ test) |
Workflow step 14; when setting up npm run verify |
assets/npmrc-exclude-block.txt |
The dated exclude block (__EXPIRES__, __REASON__, __PACKAGE__) |
Workflow step 5 |
assets/banned-packages.json |
The supply-chain floor and the offline-only and no-telemetry profiles (offline-only bans everything no-telemetry bans) |
Read by the scripts; copy entries into the policy |
assets/expo-sdk-57-module-map.json |
bundledNativeModules.json of expo 57.0.27 (what npx expo install writes), used when node_modules is absent |
Read by the scripts |
assets/shared.json |
Declares the files the library keeps in sync with other skills: the module map, every file under templates/, scripts/check-lib.mjs (edit the library's canonical copy, never these) |
When adding a shared file |
scripts/plan-dependency.mjs |
Prints the policy plan for one package (the script review before the install, companions, peers and overrides, npx expo install <pkg>@<spec>) and fails a forbidden request; --online adds age (measured like npm, from --now), deprecation, licence, scripts |
Workflow step 4 |
scripts/check-deps-policy.mjs |
Checks the manifests, lockfile, .npmrc, engines, NPM_CONFIG_* variables in eas.json and GitHub workflows, EAS_NO_FROZEN_LOCKFILE in eas.json, committed npx calls (scripts, workflows, lefthook.yml), and the policy against the rules (29 rules, 4 more when multi-package) |
Workflow step 11, and after any dependency change |
scripts/lib/policy.mjs |
Loads the policy, the ban lists and the module map; reads the repo (single app or workspaces) | Read only to change a script |
scripts/selftest.mjs |
Proves both scripts: 4 overlay suites (single app and workspaces), 98 planted bugs, passing plans and SKIP lines pinned | After changing a script, an asset, a template or a fixture |
scripts/check-lib.mjs |
Shared script helper, synced from the library (do not edit here) | Never by hand |
tests/bases/ |
The single-app and npm-workspaces base trees the suites start from | When adding a case |
tests/fixtures/ |
One folder per case (EXPECT.txt plus mutation.json, DELETE.txt, ARGS.txt or overlay files) in four suites |
When adding a rule |
Related skills
expo-sdk-upgrade- moving the app to a new Expo SDK (the policy rows move in that commit).expo-project-bootstrap- the first install,.npmrcand policy of a new app.quality-gates-npm run verifyand the gated paths; wirescripts/deps/check-deps.tsinto it.privacy-and-store-declarations- theoffline-onlyprofile, permissions and privacy manifests after a native install.expo-prebuild-and-config-plugins- config plugins a new package brings.ios-simulator-build,android-emulator-build,eas-build-and-submit- the rebuild after a native change.architecture-and-boundaries- where a new adapter goes and which files may import a vendor SDK.git-commits-and-reporting- commit format, theGate-Change:trailer and the report.