js-library-packaging
Pack placement.
engineering-base. The step asked for it to be suggested by thereactandtypescriptpacks, and the skill schema carries nosuggested_bykey — so the intent is recorded here rather than expressed in frontmatter that would fail validation. A React or TypeScript consumer receives this skill throughengineering-base, which both of those packs require.
The JavaScript twin of composer-packages. 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
- Answer buildable-vs-source-consumed FIRST. It determines the exports map, the
fileslist, 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.
- 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.
- Source of truth:
- Fix peer placement before anything else. A library carrying
reactindependenciesgives the consumer a second copy of React.- Verify:
reactandreact-domappear underpeerDependenciesonly.
- Verify:
- Order the conditions.
typesfirst, thenimport/require.- Verify: the check reports no
types-not-first.
- Verify: the check reports no
- 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: the config is the answer.
.changeset/present → changesets. Add a changeset describing the change and let the release job bump and publish. Never editversionby hand.- 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. - 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. 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
- State the classification (source-consumed / built surface / undeclared) and where it was read from.
- List every
errorfinding with its fix, then the warnings. - Name the release branch taken and the evidence in the repository for it.
- 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. filesis 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
reactorreact-domindependencies. - Do NOT write an exports map before answering buildable-vs-source-consumed.
- Do NOT place
typesafterimportorrequire. - Do NOT edit a
versionfield 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.jsonand 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— the PHP twin; same surface discipline, different manifest.standards-from-config— the Class-A rule the § Release branch follows.conventional-commits-writing— the!marker a release tool reads.ui-component-architect— the component inside the library; this skill is the package around it.