# First-publish packaging contract (/docs/publishing-contract)



Every `@pleach/*` SKU follows the same packaging conventions at its
first 1.x publish. For day-to-day consumers, this page is
informational — knowing the contract makes debugging install or
import issues faster. For forkers and vendored installs, it's
load-bearing: diverging breaks the audit gates that enforce the
contract upstream.

<SourceMeta source="{ label: &#x22;github.com/pleachhq/core/package.json&#x22;, href: &#x22;https://github.com/pleachhq/core/blob/main/package.json&#x22; }" />

## Dual ESM + CJS emit [#dual-esm--cjs-emit]

Every `@pleach/*` package ships both module systems from a single
TypeScript source tree. ESM (`.mjs`) is the primary distribution;
CJS (`.js`) is a compatibility layer for Node consumers on older
toolchains that can't resolve native ESM cleanly.

The `package.json` `exports` block routes the right format per
resolver. ESM consumers resolve `.mjs`; CJS consumers resolve
`.js`; TypeScript reads `.d.ts`. The dual emit is non-negotiable —
dropping CJS would break a substantial fraction of consumers on
Node 18 and below.

| Format | Extension | Resolver            |
| ------ | --------- | ------------------- |
| ESM    | `.mjs`    | `import` condition  |
| CJS    | `.js`     | `require` condition |
| Types  | `.d.ts`   | `types` condition   |

## Types-first `exports` ordering [#types-first-exports-ordering]

The Node spec walks `exports` conditions top-to-bottom and picks
the first match. The `types` condition must come first in each
export entry. Wrong order → TypeScript falls through to `import`
or `default`, reads the JavaScript file as if it were types, and
the consumer sees "no type declarations" errors.

```jsonc
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  }
}
```

This is a Node spec rule, not a Pleach convention — but the
upstream `audit:package-export-validation` gate asserts the
ordering anyway, since the failure mode is silent and consumer-side.

## Dir-style tsup entries [#dir-style-tsup-entries]

Build entries are directories (`src/audit/`, `src/cache/`,
`src/sessions/`), not single files. tsup walks each entry
directory's `index.ts` and emits a matching
`dist/<dir>/index.{mjs,js,d.ts}` triple.

```json
{
  "tsup": {
    "entry": ["src/audit", "src/cache", "src/sessions"],
    "format": ["esm", "cjs"]
  }
}
```

The directory convention keeps the entry list compact as the
package grows. Adding a new subpath is one entry, not three
artifact paths.

## DTS via `tsc --build`, not tsup's `dts` block [#dts-via-tsc---build-not-tsups-dts-block]

tsup's bundled-DTS mode tends to break with complex type
re-exports — it loses some declarations and mangles others when
a module re-exports a generic across barrels. The convention is
to disable tsup's DTS output entirely and run `tsc --build` for
declarations.

```json
{
  "scripts": {
    "build": "tsup && tsc --build tsconfig.build.json"
  }
}
```

tsup emits JS only; the separate `tsc` pass emits `.d.ts`. The
build script runs both in order. The `tsconfig.build.json`
typically sets `emitDeclarationOnly: true` and `declarationMap:
false`.

## Sourcemap exclusion from the published tarball [#sourcemap-exclusion-from-the-published-tarball]

Sourcemaps live on disk in dev (`dist/*.map`) but the `files`
field in `package.json` excludes them from `npm pack`.

```json
{
  "files": [
    "dist/**/*.mjs",
    "dist/**/*.js",
    "dist/**/*.d.ts",
    "LICENSE",
    "NOTICE",
    "README.md"
  ]
}
```

The empirical reason: including sourcemaps pushed the published
tarball from \~25 MB to >100 MB, which slowed CI installs across
every consumer. Sourcemaps remain available in the git repo for
debugging — clone the source tag and rebuild locally if you need
them.

## PeerDep tightening [#peerdep-tightening]

At 1.x, peer-dep ranges tighten. The typical pattern is a caret
on a single major version, sometimes narrower for packages with
known breakage windows.

| Range style    | When                                            |
| -------------- | ----------------------------------------------- |
| `^18.0.0`      | Stable peer with predictable semver             |
| `>=18.0.0 <20` | Peer with known breakage in 20.x                |
| `^18.2.0`      | Pinning to a specific minor for a bug-fix floor |

The discipline avoids the "two copies of React" duplicate-install
class of bug, where the consumer pulls one major and a transitive
dep pulls another. Consumer responsibility: install peers
explicitly rather than relying on transitive resolution.

## NOTICE, CHANGELOG, provenance, sideEffects [#notice-changelog-provenance-sideeffects]

Four baselines every 1.x publish carries.

### `NOTICE` [#notice]

License-attribution file referenced from `package.json` via the
`files` array. Required at first 1.x publish. Carries the
package's own license notice plus third-party attributions for
any bundled or derived code.

### `CHANGELOG.md` [#changelogmd]

[Keep a Changelog](https://keepachangelog.com/) format. Every
publish writes an entry; no "Unreleased" tail at publish time.
The audit gate `audit:package-version-vs-registry` (next section)
fails the publish if the changelog top entry doesn't match the
`package.json#version`.

### npm provenance [#npm-provenance]

Publishes use `npm publish --provenance`, which ties the tarball
to the GitHub Actions workflow that built it via a signed SLSA
attestation. Consumers verify with:

```bash
npm audit signatures
```

The attestation links the tarball hash to the commit SHA, the
workflow file, and the runner. Useful for supply-chain auditing
and for confirming a published artifact matches the source tag.

### `sideEffects: false` [#sideeffects-false]

Set in `package.json` where accurate — for `@pleach/*`, every
published package qualifies. The flag lets bundlers tree-shake
unused exports out of consumer bundles.

```json
{
  "sideEffects": false
}
```

The setting is honored by Webpack, Rollup, esbuild, and Vite.
Roughly a 30% bundle reduction for apps that only import a
handful of named subpaths from `@pleach/core`.

## Audit gates enforcing the contract [#audit-gates-enforcing-the-contract]

Two upstream audits gate every publish.

### `audit:package-export-validation` [#auditpackage-export-validation]

Walks every entry in `package.json#exports` and asserts:

1. The file referenced exists on disk after build.
2. The file re-exports the declared symbols (parsed from the
   matching `.d.ts`).
3. The `types` condition comes first in each entry.
4. ESM and CJS entries resolve to files with the correct
   extensions.

The gate runs in CI on every PR and as a pre-publish hook
locally. Diverging — adding an export without a corresponding
file, or reordering conditions — fails the publish.

### `audit:package-version-vs-registry` [#auditpackage-version-vs-registry]

Compares the in-tree `package.json#version` to the latest tag
on npmjs.org. Catches "silently past 1.0.0" drift between
source and registry — the failure mode where a local bump
happens but the publish never lands, then a subsequent change
publishes with the wrong version.

The gate also asserts the `CHANGELOG.md` top entry matches the
in-tree version, so the changelog can't drift either.

## Consumer-rehearsal audit [#consumer-rehearsal-audit]

Before any first-publish OR any 1.x release tag, run the
consumer-rehearsal audit — a six-phase end-to-end simulation
of what a real npm consumer experiences. The audit runs the
package through `npm pack` + install into a scratch directory
outside the source tree, then exercises the published surface
the way a consumer would.

```bash
npm run audit:consumer-rehearsal:core
```

The six phases:

| Phase | What it does                                                                                                                                         | What it catches                                                                                              |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| 0     | `npm run packages:build`                                                                                                                             | Build failures, missing artifacts                                                                            |
| 1     | `npm pack` the target + peer packages; install all tarballs into a scratch dir                                                                       | Tarball-shape regressions, missing `files` entries, peer-dep resolution failures                             |
| 2     | Auto-discovered facet smoke imports from `packages/<pkg>/src/facets/*.ts` (one accessor probe per facet)                                             | Facet barrel breakage, missing re-exports, accessor-shape drift                                              |
| 3     | Auto-discovered subpath imports from `package.json:exports` (skipping `.`, `./package.json`, wildcards)                                              | Subpath resolution failures, missing `dist/` files behind declared exports                                   |
| 4     | TypeScript consumer surface — `tsc --noEmit -p tsconfig.json` under strict `NodeNext` against a consumer file importing the published types + values | `.d.ts` regressions invisible to source-tree `tsc` (consumer resolution differs from in-monorepo resolution) |
| 5     | Auto-discovered examples runs — every `examples/<name>/index.mjs` with a 30s timeout                                                                 | Example-script breakage; functional smoke on the public surface                                              |

Exit codes: `0` = all phases pass; `1` = any phase failed.
The `--json` mode emits structured per-phase records for CI
consumption. The audit writes only to the scratch directory
outside the repo, so it's idempotent across runs.

When to run it:

* Before any first-publish of a new `@pleach/*` SKU
* Before any 1.x release tag
* After touching `packages/<pkg>/package.json:exports`
* After relocating or renaming any `src/facets/*.ts` accessor
* After adding new files to the `files` array

The audit caught the standalone-install blocker on `@pleach/core`
that motivated the no-eager-react-import fix — a failure mode
that was invisible to every in-monorepo gate because workspace
path aliases silently resolved cross-tree.

## For consumers — debugging install issues [#for-consumers--debugging-install-issues]

Three common symptoms and what they usually mean.

| Symptom                                               | Likely cause                                                                                        |
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `Cannot find module "@pleach/core/<subpath>"`         | The subpath isn't in the exports map — check [Subpath exports](/docs/subpath-exports)               |
| `Module has no exported member` on a type-only import | `types` condition out of order in the consumer's bundler resolver — pin to a recent bundler version |
| Sourcemap warnings in dev tools                       | Benign — the tarball deliberately excludes sourcemaps                                               |

The first one is the most common: a deep import that worked
against `@pleach/core/*` (wildcard) before a named barrel
landed, then stopped resolving the way the consumer expected.
The fix is to import from the barrel, not the deep path.

The second usually means an old bundler or `moduleResolution:
"node"` instead of `"bundler"` / `"node16"` in the consumer's
`tsconfig.json`. The types-first ordering is correct on the
package side; the resolver needs to honor it.

## For forkers and vendored installs [#for-forkers-and-vendored-installs]

If you vendor `@pleach/*` into a private registry or fork the
source, mirror the conventions above. The upstream audit gates
will fail on a fork that diverges — the gates live in the
package source, not in the publish pipeline, so they run in any
fork's CI too.

The canonical enforcer scripts live in the source tree:

| Script                              | Asserts                                          |
| ----------------------------------- | ------------------------------------------------ |
| `scripts/audit-package-exports.mjs` | Exports map validity, types-first ordering       |
| `scripts/audit-package-version.mjs` | In-tree version vs registry, changelog top entry |
| `scripts/audit-build-artifacts.mjs` | Dual-format emit, DTS presence, tarball contents |

A fork that wants to publish under a different scope should keep
the gates intact and update the registry URL the version audit
checks against. Don't disable the gates — the failure modes they
catch are silent on the consumer side, and disabling them shifts
debugging cost onto every downstream user.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Subpath exports" href="/docs/subpath-exports" description="The named import paths under @pleach/core and what each ships." />

  <Card title="Versioning policy" href="/docs/versioning" description="Semver, deprecation, pinning rules, and the FSL-1.1-Apache-2.0 license posture." />

  <Card title="Packages" href="/docs/packages" description="The @pleach/* package matrix — what each SKU does." />

  <Card title="Contributing" href="/docs/contributing" description="How to file a docs bug or propose a contract change." />
</Cards>
