js-library-packaging · git:20260823.bab97ce · 2026-08-23 · sha256 be62ed5a8aea3943
js-library-packaging git:20260823.bab97ceA
Immutable. This exact content is served forever at /api/v1/blob/be62ed5a8aea3943.
---
model_tier: medium
name: js-library-packaging
description: "Use when a JavaScript/TypeScript package is consumed by another package — exports map, peer vs. direct dependencies, files allow-list, and whether it should be built at all."
domain: engineering
workspaces:
- engineering
packs:
- engineering-base
---
# js-library-packaging
> **Pack placement.** `engineering-base`. The step asked for it to be *suggested by* the
> `react` and `typescript` packs, and the skill schema carries no `suggested_by` key — so the
> intent is recorded here rather than expressed in frontmatter that would fail validation.
> A React or TypeScript consumer receives this skill through `engineering-base`, which both
> of those packs require.
The JavaScript twin of [`composer-packages`](../composer-packages/SKILL.md). A component is
a file; a **library** is a package with a public surface, and that surface is declared in
`package.json` rather than inferred. Most of the failures below are silent at author time
and loud at the consumer's — which is why the surface is read, not assumed.
## The Iron Law
```
`react` AND `react-dom` ARE peerDependencies. NEVER dependencies.
`types` IS THE FIRST KEY OF EVERY CONDITIONS OBJECT.
DECIDE buildable-VS-source-consumed BEFORE WRITING THE EXPORTS MAP.
NEVER HAND-BUMP A VERSION.
```
## When to use
A package in the repository is imported by another package, or is about to be published:
an `exports` map is being written or changed, a hook fails at runtime with *"invalid hook
call"*, an install fails on a `workspace:` range, a release needs cutting, or the
buildable-vs-source question has not been answered yet.
## Procedure
1. **Answer buildable-vs-source-consumed FIRST.** It determines the exports map, the `files`
list, and whether a build step exists at all — deciding it after writing the map means
rewriting the map.
- **Source of truth:** is the package published to a registry, or only consumed inside
this workspace?
- **Verify:** the exports targets all point into one of the two worlds (`src/` or the
build directory), never a mix.
2. **Read the surface rather than asserting it.** Run the check below over the package root.
- **Source of truth:** `package.json` — `exports`, `dependencies`, `peerDependencies`,
`files`, `private`, `publishConfig`.
- **Verify:** zero `error`-severity findings.
3. **Fix peer placement before anything else.** A library carrying `react` in
`dependencies` gives the consumer a second copy of React.
- **Verify:** `react` and `react-dom` appear under `peerDependencies` only.
4. **Order the conditions.** `types` first, then `import` / `require`.
- **Verify:** the check reports no `types-not-first`.
5. **Cut the release through the repository's own mechanism** — never by editing a version
field. See § Release.
- **Verify:** the version change is produced by the tool the repository already carries.
## Read the surface
```
check_package_surface <library-root> [<library-root> …]
```
`scripts/check_package_surface.ts` beside this skill. Deterministic, JSON on stdout, no
network and no subprocess. It reports:
| Code | Severity | Why |
|---|---|---|
| `peer-as-dependency` | error | two copies of React in one tree break hooks at runtime, with a message that names neither package |
| `export-target-missing` | error | the manifest promises a file the package does not ship |
| `no-exports-map` | warn | `main`/`module` without `exports` — the entry point resolves by bundler convention, not by declaration |
| `types-not-first` | warn | conditions match in declaration order, so a later `types` is never seen and the package silently ships untyped |
| `workspace-range-publishable` | warn | a `workspace:` range does not resolve outside the workspace; published, the consumer's install fails |
**It classifies from the declared export targets, never from a directory name.** A package
directory called `ui-lib-vite` is not evidence about Vite, and one called `buildable` is not
evidence that anything builds. A **mixed** declaration is reported as `undeclared` rather
than resolved to a guess — declaring both is the ambiguity worth surfacing.
**What it does not do:** it never compiles and never runs a bundler, so it cannot tell you
the declared layout is producible. A checker that grew a parser would report compile errors
as packaging errors, and the two have different fixes.
## Buildable vs. source-consumed
| | source-consumed | buildable |
|---|---|---|
| exports point at | `src/` | the build directory |
| build step | none | one, and it must run before publish |
| `files` | not needed (nothing published) | the build directory, as an allow-list |
| `private` | `true` on a workspace-only package | absent; `publishConfig.access` set instead |
| cost | consumer compiles it | a build to maintain, and output that can go stale |
**Default to source-consumed inside a workspace.** A build step exists to serve publication;
adding one for a package nobody publishes is a maintenance cost with no consumer.
## Release
Read the repository, then take exactly one branch — **Class A**, per
[`standards-from-config`](../standards-from-config/SKILL.md): the config *is* the answer.
1. **`.changeset/` present → changesets.** Add a changeset describing the change and let the
release job bump and publish. Never edit `version` by hand.
2. **A task runner with a release command (e.g. `nx release`) → that command.** It owns
version, tag, and changelog together; splitting them by hand desynchronises them.
3. **Neither present → none configured.** **Propose** a mechanism and stop. Do not hand-bump:
a manually edited version has no changelog entry and no tag, so the next release cannot
tell what shipped.
A breaking change carries the `!` marker and the footer — see
[`conventional-commits-writing`](../conventional-commits-writing/SKILL.md). The marker is
what a release tool reads to decide the major bump, so omitting it publishes a breaking
change as a patch.
## Output format
1. State the classification (source-consumed / built surface / undeclared) and where it was
read from.
2. List every `error` finding with its fix, then the warnings.
3. Name the release branch taken and the evidence in the repository for it.
4. Say explicitly what was not checked — buildability, types, runtime behaviour.
## Gotchas
- **"Invalid hook call" names neither package.** The developer sees a React error and looks
at their component; the cause is a library shipping its own React. This is why peer
placement is an error and not a warning.
- **Condition order is silent.** `{ "import": …, "types": … }` type-checks fine at author
time and ships untyped to the consumer.
- **A stale build directory passes every metadata check.** The manifest points at files that
exist; nothing says they match `src/`. Only a real build proves that.
- **`files` is an allow-list, not a deny-list.** Adding a directory to the package means
adding it here, and forgetting is invisible until a consumer imports a missing path.
- **A `workspace:` range is not a version.** It resolves in the workspace and nowhere else.
## Do NOT
- Do NOT put `react` or `react-dom` in `dependencies`.
- Do NOT write an exports map before answering buildable-vs-source-consumed.
- Do NOT place `types` after `import` or `require`.
- Do NOT edit a `version` field by hand, in any of the three release branches.
- Do NOT claim the package builds because the check passed — it never ran a build.
## Security constraints
`scripts/check_package_surface.ts` is **read-only and offline by construction**, and the
constraint is worth stating because a packaging tool is a tempting place to lose it:
- **No network.** It never resolves a version, queries a registry, or checks whether a name
is taken. Every verdict comes from the manifest in front of it.
- **No subprocess.** No package manager, no bundler, no compiler. It cannot install, build,
or publish, so running it on an untrusted repository executes none of that repository's code
— which a package manager's lifecycle scripts would.
- **No writes.** It reads `package.json` and tests path existence. It never edits a manifest,
so it can never "fix" a version field, which is the one thing § Release forbids.
- **Paths come from the caller, targets from the manifest.** Export targets are resolved
relative to the given root and used only for an existence test; nothing is read from them.
Widening any of these turns a metadata reader into something that runs a third party's code.
If a future check needs a build, it belongs in a separate integration test with its own
declared cost — never here.
## See also
- [`composer-packages`](../composer-packages/SKILL.md) — the PHP twin; same surface discipline, different manifest.
- [`standards-from-config`](../standards-from-config/SKILL.md) — the Class-A rule the § Release branch follows.
- [`conventional-commits-writing`](../conventional-commits-writing/SKILL.md) — the `!` marker a release tool reads.
- [`ui-component-architect`](../ui-component-architect/SKILL.md) — the component inside the library; this skill is the package around it.