# AGENTS.md Repository-level guide for AI coding agents working on `maxrects-packer`. Human contributors should start with [CONTRIBUTING.md](./CONTRIBUTING.md) instead. ## Project `maxrects-packer` (MIT, zero runtime dependencies) is a **MaxRects 2D bin packing** library written in TypeScript. It aims to pack arbitrary `{width, height}` rectangles into as few bins as possible, each staying within `maxWidth × maxHeight` (sprite sheets / texture atlases). - **Packing is a heuristic, not an optimal solver — never claim a minimum bin count.** `addArray()` sorts the input, `add()` first-fits each rect into the existing bins in order, and inside a bin `findNode()` greedily takes the best-scoring free rectangle for `options.logic`. Nothing searches for a global optimum, so a change may only ever claim "fewer bins than before **on these inputs**", measured — never "the fewest possible". - Geometry only: it never reads or writes images or files and depends on no DOM/Node API, so it runs in the browser as well. - The target use case is WebGL/atlases: opening another bin is preferred over emitting a single gigantic image. - It is a rewrite of [Multi-Bin-Packer](https://github.com/marekventur/multi-bin-packer) and the public API is kept compatible, so do not change signatures casually. ## Layout | File | Responsibility | | --- | --- | | `src/index.ts` | The only barrel export. Runtime values: `Rectangle / MaxRectsPacker / PACKING_LOGIC / Bin / MaxRectsBin / OversizedElementBin`; types: `IRectangleInput / IRectangle / IRectangleJSON / IOption / IBin`. The surface is pinned by `test/index.spec.js` — adding or renaming a public value means updating that list in the same commit — and what consumers can actually import is checked by the type fixture in `npm run verify:package` against `package.json`'s `types` entry | | `src/types.ts` | `IOption`, `PACKING_LOGIC` (MAX_AREA/MAX_EDGE/FILL_WIDTH), `EDGE_MAX_VALUE=4096` (the default edge size of `MaxRectsBin`/`MaxRectsPacker`), `EDGE_MIN_VALUE=128` (**never used inside the library; both are re-exported by `src/maxrects-packer.ts` and part of the public `.d.ts` — not dead code, do not delete**) | | `src/geom/Rectangle.ts` | `IRectangle` interface + `Rectangle`: `width/height/x/y/rot/data/allowRotation` all go through getters/setters and bump `_dirty` on every mutation; the `rot` setter swaps width/height, the `data` setter syncs `data.allowRotation`; the `Clone` static copies a rect for the bins' `clone()` — prototype and own properties, shallow, or the rect's own `clone()` when its class has one | | `src/abstract-bin.ts` | `IBin` / abstract `Bin`: the `dirty` semantics and `setDirty()`; `add/reset/repack/clone` are left to subclasses | | `src/maxrects-bin.ts` | Core single-bin algorithm: `place → findNode(scoring) → updateBinSize(expand) → splitNode(split) → pruneFreeList` | | `src/oversized-element-bin.ts` | Placeholder bin for one oversized element (`rect.oversized = true`, `add()` always returns `undefined`) | | `src/maxrects-packer.ts` | Multi-bin scheduling: `add / addArray / next / repack / save / load / sort / rects` | Data flow: ``` caller's rects → addArray: sort (MAX_EDGE|MAX_AREA, ties broken by hash; group by tag first when tags are not exclusive) → add: out of bounds? → OversizedElementBin; otherwise take the first bin from bins[_currentBinIndex..] that fits → MaxRectsBin.place: findNode scores free rects by logic → updateBinSize grows the bin (pot/square) → splitNode splits → pruneFreeList → write x/y/rot back onto that very rect in place and push it into bin.rects ``` ## Invariants you must preserve These are the contract with downstream users — breaking one is a breaking change. The longer version, with the reasoning, is [docs/contributor/behavior-contracts.md](./docs/contributor/behavior-contracts.md). 1. **In-place mutation**: `add()/addArray()` write `x/y/rot/oversized` directly onto the object passed in, and nothing else — a rect the caller sealed still packs, so the library never adds a property to one. `add()` returns that same object — except the multi-argument overload, which constructs an internal `new Rectangle` and returns that instead — while `addArray()` returns nothing. Input arrays keep their order in every mode; `addArray()` accepts readonly arrays and sorts copies. Supplied payloads, including explicit `null`, retain their value; omitted numeric data keeps `{}`. 2. **Tag grouping**: with `exclusiveTag: true` (the default) a tagged rect may only enter a bin with the same tag, and an untagged bin refuses tagged rects. The check lives in `MaxRectsBin.place()`, which treats falsy labels as untagged on both sides while preserving bin label values for saving, and which every path reaches — `MaxRectsBin.add()` deliberately does not pre-check, so a refused rect is simply the `undefined` `place()` returns. `exclusiveTag: false` takes the grouping + recursion path inside `addArray()`, which probes a candidate bin by **copying** it: a bin whose `clone()` throws (invariant 8) counts as one the group does not fit, so a single uncopyable bin can never fail the whole `addArray()` call. 3. **`next()` only affects what comes after**: it sets `_currentBinIndex = bins.length`, so earlier bins stop accepting new elements and every lookup starts at that index. 4. **Dirty propagation**: mutating a `Rectangle` property increments `_dirty`; `Bin.dirty` is true when its own `_dirty > 0` or any of its rects is dirty; `repack(quick=true)` only re-packs dirty bins. Changing setter semantics breaks incremental repack. 5. **save/load store free space only**: bins produced by `save()` always carry an empty `rects` array — dimensions/options/tag/freeRects only; `load()` restores free rectangles only. Already-placed rects have to be re-`add`ed by the caller (tests lock this behavior in — do not "helpfully" fill it in). Saved options are shallow copies, not live aliases; explicit empty labels survive save/load. Bins are library-owned: full repacking rebuilds them without migrating bin metadata. Durable business data belongs on rects, with names that avoid the rect protocol's fields. 6. **Size accounting**: the initial freeRect is `maxWidth + padding - border*2`; placement probes with `rect.width + padding`; inside `updateBinSize`, pot/square are rounded first and only then validated against `maxWidth/maxHeight`, and growth is abandoned when that check fails. A bin never holds a rect it cannot contain: `place()` refuses the placement when the bin cannot grow to the node it picked (which is what a `square` cap below `maxHeight` makes possible), and `MaxRectsPacker.add()` then puts that rect in an `OversizedElementBin` instead of dropping it. The other half of the same rule: a rotated rect reports the footprint it occupies, so `place()` swaps width/height itself for objects whose `rot` has no setter. A size that is not a finite number can never become a bin: `updateBinSize()` refuses a growth whose result is not finite, and both constructors reject numeric `NaN`/`Infinity`/`-Infinity` padding. Omitted or `undefined` padding defaults to `0`; other JavaScript values retain their original coercion behaviour, and a non-finite result refuses placement instead of retrying forever (issue #43). 7. **Generics**: `MaxRectsPacker` / `MaxRectsBin` accept instances of custom classes and preserve their extra properties as-is. `T` defaults to `IRectangleInput` — `width`/`height` plus an index signature — so the plain `{ width, height }` object every guide passes compiles with no type argument (issue #46). Naming `Rectangle`, or your own class, buys that class back on `bins[i].rects[i]`. `IRectangle` is the placed shape: `IRectangleInput` plus the `x`/`y` the packer writes, and it is what `freeRects` and the `collide`/`contain` helpers take. 8. **`clone()` isolates the two bins**: both implementations copy the rects (`Rectangle.Clone`, so a custom class keeps its prototype and its extra fields) and carry over `tag` and `data`. Mutating a rect one bin holds, or adding to one bin, therefore never reaches the other. The copy is **shallow** — the object in `rect.data` is shared — and it is produced by re-packing the copies, which reproduces the source's placements: the replay is handed each rect in the orientation the source was handed it, and the bin keeps a per-placement record of the rects it turned, because the state alone cannot say whether this bin turned a rect or the caller handed it over already rotated. The record lives on the bin rather than on the rect, which invariant 1 forbids adding fields to (`test/clone.spec.js` pins both reproducers that used to land differently or refuse to build, the sealed-rect case, and sweeps seeded tight bins of plain and already-rotated rects). Two things are outside the guarantee, because the replay runs under the bin's current `options`: a rect the caller **edits after** it was placed, where the replay places what the rect is *now* and a resized rect can land elsewhere or be refused, and **`options` changed after** the placement, where the recorded orientation can stop being one the bin takes — the rect then falls back to the orientation it is stored in, and a rect neither orientation fits is still reported. That replay runs with the tag gate **off** and restores the source's `options`/`tag`/`data` afterwards: a bin can hold rects its own gate would now refuse — it was tagged after it was filled, or it carries several tags in non-exclusive mode — and re-running the gate would give back a copy with fewer rects than the original. `Rectangle.Clone` copies *own property descriptors*, so extra fields and non-enumerable backing fields survive. When the class has its own `clone()`, that method builds the copy and everything else follows one rule: **state travels, behaviour does not.** The copy's own accessors, functions and non-configurable properties win; plain values, and every key the copy does not define, come from the source; no own function and no own accessor is ever taken from the source — prototype methods arrive with the instance the `clone()` returns, and an own one is that method's to define, because a `this`-based method and a closure over the source cannot be told apart from outside (a source-text check got both directions wrong over six review rounds). The copy is shallow. Two shapes are documented rather than detected: an own accessor closing over the source, with no `clone()`, stays shared with the copy; `#private`-backed state with no `clone()` fails loudly. Details and the full list: [behaviour contracts](./docs/contributor/behavior-contracts.md#clone-isolates-the-two-bins). 9. **`toJSON()` writes the public shape**: `JSON.stringify()` on a `Rectangle` produces the same object as the plain `{ width, height }` rect the packer also accepts — `width`, `height`, `x`, `y`, `rot`, in that order, plus `oversized` when set, `allowRotation` when the rect carries an override, `data` unless it is still the constructor's untouched `{}` placeholder, and any other **enumerable** own field — the class's backing fields are dropped **by name**, so a caller's `_assetId` survives. Enumerable is the boundary `JSON.stringify()` draws on a plain object: a field the caller hid stays hidden, and a getter the caller hid is never read. That name test is an exact membership check: a plain lookup also reads `toString`/`constructor` off the prototype chain and would drop a caller field named after one, and an own `__proto__` field has to be defined rather than assigned or it repoints the result's prototype. Values come from the public getters (a subclass may override `data` or `allowRotation`). Two tests decide whether the payload is the placeholder, and neither is redundant. Identity first: is it the very object the constructor installed? That is the only thing that sees a `{}` the caller wrote — through the setter or by direct assignment from a subclass — and going first also spares a caller's payload the key scan the shape test performs. Shape second: a plain empty object that still has its original `Object.prototype`, which is the only thing that sees a placeholder mutated where it stands, by an added key or by a prototype replaced with one that serializes itself. The placeholder is tracked as that object rather than as a flag the setter clears, because a direct assignment never reaches the setter. The return type is `IRectangleJSON`, so the result assigns to `IRectangleInput` with no cast. `JSON.stringify()`'s argument is typed `any`, so a fixture that pins this has to call `toJSON()` directly — a `JSON.stringify()` line compiles whether or not the method exists. ## Documentation map The documentation site is VitePress, with its API pages generated from the JSDoc in `src/` by TypeDoc. The tree is tracked **source**; `docs/api/`, `docs/.vitepress/cache/` and `docs/.vitepress/dist/` are build products, and `npm run verify:docs` asserts both halves in CI. | Path | Holds | Read it when | | --- | --- | --- | | `docs/user/` | User guide: options, packing, rotation and tags, repacking, persistence, troubleshooting | answering "how do I use this" | | `docs/contributor/` | Development, testing, architecture, behaviour contracts, compatibility, documentation, releasing | changing code or process | | `docs/spec/YYYY-MM-DD-.md` | Designs and specifications | before implementing a design | | `docs/plans/` | Execution plans and the deferred-work ledger | picking up unfinished work | | `docs/api/` | API markdown generated from JSDoc | never edited by hand | Routing rules for new material: 1. User-facing details belong in `docs/user/`; contributor and process details in `docs/contributor/`. 2. Agent-authored designs go to `docs/spec/YYYY-MM-DD-.md`, execution plans to `docs/plans/YYYY-MM-DD-.md`. 3. Do not create tool-specific roots such as `docs/superpowers/`. 4. Never edit generated API markdown or built site files. 5. A behaviour change updates the relevant tests, JSDoc and guide pages together. 6. Ongoing findings go to the deferred-work ledger rather than growing this file. ## Commands ```bash npm ci --include=dev # NODE_ENV=production makes npm skip devDeps, so --include=dev is required npm test # = rimraf dist + lib, rollup build, vitest run npm run typecheck # native TypeScript 7; scripts/typecheck.mjs asserts the tsc version first npm run verify:package # npm pack → install into a temp consumer → verify require/import by package name (build first) npm run lint # oxlint (baseline is 0 warnings / 0 errors) npm run lint:fix # oxlint --fix npm run format # oxfmt writes back; CI only checks, via npm run format:check npm run cover # build + vitest run --coverage + thresholds + coverage artifact self-check npm run docs:api # TypeDoc markdown → docs/api/ (ignored); docs:dev / docs:build / docs:preview for the site npm run docs:clean # removes docs/api, docs/.vitepress/cache and docs/.vitepress/dist, nothing else npm run verify:docs # asserts that boundary and the ignore rules npx vitest run test/maxrects-packer.spec.js # run a single spec (no rebuild needed) ``` - Toolchain: rollup + `@rollup/plugin-typescript` for bundling, vitest for tests, oxlint/oxfmt for linting and formatting, `commit-and-tag-version` for releases. - **Lint enforcement**: `oxlint` reports warnings but **only errors fail CI**, even though the baseline is genuinely `0 warnings / 0 errors`. Four rules in `.oxlintrc.json` are deliberately set to `warn` (`no-console`, `no-unused-vars`, `no-unneeded-ternary`, `jsdoc/require-property-type`), so they are advisory only — `npm run lint` exits 0 with a warning present. Adding `--deny-warnings` to the `lint` script is what would make them blocking; that has not been done on purpose. `vitest/expect-expect` is the exception that is an **error**, configured with `assertFunctionNames: ["expect", "fillWithRandomRects"]` — the list replaces the default rather than extending it, so a new helper that asserts on the caller's behalf has to be named there or the rule fires on its tests. - **Dual TypeScript (the official 6/7 side-by-side setup)**: the `typescript` alias points at `@typescript/typescript6`, which provides the JS compiler API consumed by typedoc and the rollup plugin; `@typescript/native` is native TS 7 and `node_modules/.bin/tsc` points at it. So `npm run typecheck` runs TS 7 while the build and docs run the TS 6 API — the tests do not, because vitest transforms TypeScript through Vite's pipeline — Oxc since Vite 8, with esbuild gone from the tree — and never touches the compiler API. Both sides matter when you touch tsconfig: TS 7 removed `baseUrl` / `moduleResolution: node10` / `target: es5`, which is why `tsconfig.json` (type checking) stays clean, while `target: es5` in `tsconfig.build.json` is kept quiet for TS 6 by `ignoreDeprecations: "6.0"`. `@typescript/typescript6` is a forwarding shim, not a compiler: `lib/typescript.js` is the single line `module.exports = require("@typescript/old")`, and `@typescript/old` is its own dependency on the stock `typescript@^6`. `require("typescript")` therefore still yields the TS 6 JS API, while `node_modules/.bin/tsc6` is the unambiguous way to run TS 6 and `.bin/tsc` stays native TS 7. - Test specs are ESM JavaScript that `import` straight from the TypeScript sources (`../src/xxx`, extension-less) and take `describe / test / expect / beforeEach` from `vitest` explicitly instead of from globals — **they do not test `dist`**. A broken build or a broken artifact is invisible to them, so compare `dist` by hand whenever you touch the build. - Baseline: `8 spec files / 126 passed / 2 skipped`; v8 coverage is 100% on statements, branches, functions and lines — removing the dead code recorded in `docs/plans/deferred-work.md` took the last uncovered range with it, so no file has a gap left to read. Coverage is **opt-in**: only `npm run cover` collects it and writes `test/coverage/` (gitignored), so a plain `npm test` or a single-spec run leaves that directory alone. Read the real numbers from a full `npm run cover`, and take the *gap* list from its JSON/lcov output rather than from the text table — that table's `Uncovered Line #s` column omits lines the machine-readable report marks as never executed (checked under both the old istanbul and the current v8 provider). - **The numbers are gates, not measurements.** `test/index.spec.js` fails when the barrel's export list, binding identity or `PACKING_LOGIC` numbering changes; `coverage.thresholds` in `vitest.config.js` (99/98/99/99, deliberately below the measured values so a fraction of drift does not fail a legitimate change) fails the run on a coverage drop; and `scripts/verify-coverage.mjs` fails when `lcov.info` or `coverage-final.json` is missing, unparseable or incomplete — vitest exits **0** when the `coverage.reporter` key is wrong, so without that check a silently lost artifact stays green. - The two `test.skip`s in `test/efficiency.spec.js` are the bulk comparison table driven by `scenarios.json` + `ascii-table`; they only run once un-skipped by hand. The rest of that file does assert: every candidate packs every scenario completely, `combined best of` really picks the better logic under the lexicographic rule it implements (fewer bins wins, efficiency only breaks a tie), and no candidate reports an efficiency above 1 or leaves a rect outside the bin it was put in — a bin that reports less than it holds is what both of those catch. Each candidate packs its **own copy** of the fixture, because `addArray()` writes `x/y/rot` back onto the rect objects and a rotated rect carries swapped dimensions afterwards, so a shared fixture would let one candidate measure what the previous one left behind. - CI: `.github/workflows/node.js.yml` (Node 20/22/24: lint → format:check → typecheck → verify:docs → docs:build → cover → verify:package); `release.yml` is triggered by `v*` tags and builds the GitHub release, while `publish.yml` runs from the same tags and publishes to npm through OIDC trusted publishing (no `NPM_TOKEN` secret exists; the trusted publisher is configured on npmjs.com and names `publish.yml`). ## Worktrees The **default worktree location for this repository is `.worktrees/`**: when you need parallel or isolated changes, create a linked worktree under the repository root rather than outside it. ```bash git worktree add .worktrees/ # existing branch git worktree add .worktrees/ -b # new branch git worktree remove .worktrees/ # clean up when done ``` - `.worktrees/` is in `.gitignore`; never commit it, and never put version-controlled files in it. - Each worktree is a separate working directory: `node_modules/`, `dist/`, `lib/` and `test/coverage/` are not shared, so run `npm ci --include=dev` on first use or `npm test` will fail on missing dependencies or pick up stale artifacts. - The main worktree (repository root) is still the default place to work; reach for `.worktrees/` only when isolation is genuinely needed. ## Contribution rules **Every piece of repository communication is written in English** — commit messages, PR titles and descriptions, issue reports and comments, code comments and documentation. This library is a dependency of software all over the world, and `CHANGELOG.md` is generated from commit messages, so English keeps the project history usable for every contributor and every downstream consumer. - **Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/): `type(scope): subject`, imperative mood, lower-case subject, no trailing period. Types used so far: `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`, `chore`. - **A PR title must itself be a valid conventional commit.** The repository sets `squash_merge_commit_title: PR_TITLE`, so the pull request title becomes the commit subject on `master` — which is what `commit-and-tag-version` parses to build `CHANGELOG.md`. Retitling the PR is therefore the way to correct the type. `feat:`/`fix:` land under Features/Bug Fixes while `chore:`/`docs:`/`ci:`/`build:`/`test:`/`refactor:` stay hidden from the changelog — pick the type for its user-visible effect, not for the size of the diff. - **A breaking change must be marked in a commit message**, either with `!` (`feat!:`) or with a `BREAKING CHANGE:` footer. The squash commit body is assembled from the branch's commit messages (`squash_merge_commit_message: COMMIT_MESSAGES`), so a footer written only in the PR description never reaches `master` — and then `commit-and-tag-version` cuts a minor (for `feat`) or a patch (anything else) instead of a major. - **Never hand-edit `CHANGELOG.md` or the version in `package.json`** — `commit-and-tag-version` owns both. - Keep a PR to one logical change, and state in the description what you verified and how. - New public API needs JSDoc; a behavior change needs a test that fails before the change and passes after it. - Report suspected security issues privately to the maintainer rather than in a public issue — see the "Security" section of [CONTRIBUTING.md](./CONTRIBUTING.md). ## Conventions - Read the relevant `src` module before changing it (algorithm work usually couples `place/findNode/updateBinSize` together). - Formatting is `oxfmt`'s job (4 spaces, double quotes, semicolons, printWidth 120, no trailing comma, no space between a function name and its parentheses) — never align by hand. Keep explicit `public/private` modifiers, and give new public API JSDoc (`@param` with a `- `, `@property` with a type). - Line endings are LF everywhere (`.gitattributes` has `* text=auto eol=lf`) — do not let an editor flip them back to CRLF. - Run `npm test` after any algorithm change: the randomized monkey tests in `maxrects-bin.spec.js` (overlap + out-of-bounds assertions) are the main regression net. For edge cases with fixed input, add explicit assertions instead of relying on randomness. - Commits follow conventional commits (`feat/fix/chore/docs`) and `commit-and-tag-version` generates `CHANGELOG.md`; do not bump versions by hand. ## Known pitfalls (treat as current behavior — do not "fix" them into something else) - **ES5 output is the only thing blocking an esbuild/rolldown pipeline**: `target: es5` in `tsconfig.build.json` makes the tsc family the only compiler path that can emit ES5 (esbuild fails outright with `Transforming const to the configured target environment ("es5") is not supported yet`). Should ES5 compatibility ever be dropped, the build can move wholesale to esbuild/rolldown and the dependency on the TS 6 JS API goes away with it. - `typedoc` only supports the TS 6 JS API so far (peer `… || 6.0.x`), which is exactly why `typescript` has to stay aliased to 6.x; the alias can become a real `typescript@7` once typedoc supports TS 7. - **vitest is pinned to the 4.x line, and regenerating the lockfile with npm 10 needs a flag.** 5.x requires Node `^22.12.0 || ^24.0.0 || >=26.0.0`, which would drop the Node 20 leg of the CI matrix; 4.x still accepts `^20`. And `npm install` on npm 10.9.8 dies inside arborist (`Cannot read properties of null (reading 'edgesOut')` from `#loadPeerSet`) on vitest's peer set unless it is run with `--legacy-peer-deps` — `npm ci` is unaffected and installs the committed lockfile on npm 10, 11 and 12. - **vitepress is deliberately on the `2.0.0-alpha` line, and that is what clears the Dependabot alerts.** `vitepress@1.6.4` pins `vite: ^5.4.14`, which nested `vite@5.4.21` and `esbuild@0.21.5`; both lines are dead ends — 5.4.21 and 0.21.5 are the newest releases they ever got, and no advisory backported a fix below `vite@6.4.2`. `2.0.0-alpha.20` asks for `vite: ^8.2.1` instead, so the tree carries a single `vite@8.3.4` and no `esbuild` at all (vite 8 is rolldown-based). Do not "downgrade to stable" before `2.0.0` ships: `1.6.4` is what put the four alerts back. The site is default-theme only — `docs/.vitepress/` holds `config.mts` and nothing else — so a bump is a config question, not a theme migration. - **npm will not reconcile a changed package identity while the locked version still fits.** If a dependency's spec starts naming a different package — the swap to an `npm:` alias — and the version already in the lockfile still satisfies the new range, `npm install` silently reuses that node: it reports `up to date`, the entry stays plain (no `name` field of its own), so `npm ci` installs the pre-alias package and the alias never appears (its bin, `tsc6`, is missing from `node_modules/.bin`). npm matches a locked node by "version satisfies range" and never re-checks which package the spec now names, so it *does* reconcile as soon as the version no longer fits — an exact `…@6.0.2` pin or a move to `^5.9.0` both update the entry. Reproduced on npm 10.9.8 and 12.0.2, with and without `--package-lock-only`; [npm/cli#4592](https://github.com/npm/cli/issues/4592) is the same family of alias/lockfile bugs and is still open. Remedy: delete the stale `node_modules/` entry from `package-lock.json`, re-run `npm install --package-lock-only`, then confirm the entry carries the alias target in its `name` field — `scripts/typecheck.mjs` asserts exactly that, so CI catches a regression. `npm ls ` is **not** a usable check here: this environment sets `NODE_ENV=production`, which hides every devDependency from `npm ls` unless `--include=dev` is passed, so the tree looks empty in the broken and the fixed state alike. - **A rect dimension is coerced, not validated.** `add()` takes whatever the arithmetic can coerce: a `null` width behaves as `0`, `"10"` compares as `10` (while `"10" + padding` concatenates, so the probe is a different box than the rect reports), `true` and `[]` are accepted too. Only `NaN` — what `undefined` computes as — is refused, and that refusal comes from the size accounting (issue #43's fix), not from rect input validation. Do not turn it into a type check: callers rely on the coercion. - `MaxRectsBin.reset(true, true)` replaces `options` with an incomplete object: `exclusiveTag`/`logic` are missing and `square` becomes `true` (unlike the class default). - **`OversizedElementBin`'s two-argument construction is a supported tolerance, not dead code.** The second overload declares `data?: any` so TypeScript callers can use the form JavaScript has always had; leaving `data` out makes the bin report `null`, and its inner rect keeps `{}` because the constructor only assigns rect data when the third argument is supplied. Explicit `null` is preserved on both fields. `test/oversized-element-bin.spec.js` pins that default and the type fixture in `npm run verify:package` pins the declaration — a dead-code pass removed the fallback as unreachable and this review had to put it back. - **Per-rect `allowRotation` is much narrower than it looks.** Only `MaxRectsBin.place()` honours it, and only when the rect object itself carries an own `_allowRotation` property — true for `Rectangle` instances (its `data` setter maintains it) but **not** for the plain `{width, height}` objects the README advertises. On top of that, `MaxRectsPacker.add()` decides "oversized" from `options.allowRotation` alone, so a per-rect `allowRotation: true` **cannot** rescue a rect that only fits when rotated while the packer option is `false` — it goes straight to `OversizedElementBin`. Measured on the built bundle; the spec named "Per rectangle allow rotation" only covers rects that already fit unrotated, so it does not catch this. What a *rotated* plain rect reports is no longer part of the gap: `place()` swaps its width/height itself, so it describes the footprint it occupies just like a `Rectangle` does (invariant 6). - Packaging / entry points: `package.json` is `"type": "module"`, so a `.js` file inside the package is parsed as ESM by Node — which is why `main` must point at `.cjs` (`dist/maxrects-packer.cjs`). Historically `main` pointed at the UMD `.js`, and `require()` returned an empty object for three and a half years. Three gates protect the entry points: `postbuild` runs `scripts/fix-declaration-extensions.mjs` (rewrites the relative specifiers in `dist/**/*.d.ts`, see below), then `scripts/build-cjs-declarations.mjs` (writes the `.d.cts` set the `require` condition hands out, see below) and then `scripts/verify-entry.mjs` (fast, checks the `main`/`module`/`types` paths plus every `exports` condition target), while CI runs `npm run verify:package` (slow — `npm pack`s a real tarball, installs it into a temp consumer and verifies `require`/`import` by **package name**, then compiles two fixtures against the published declarations: an ESM one under `bundler`, `node16` and `nodenext`, and a CommonJS `.cts` one under `node16` and `nodenext`). All of them will stop you after a change to the entry points, `files`, `exports` or artifact names. `postbuild` also runs `scripts/verify-es5.mjs`, which fails the build when a bundle calls a runtime API newer than ES5 (`Array.prototype.find`, `Object.getOwnPropertyDescriptors`, `Reflect.ownKeys`). `target: es5` downlevels syntax and polyfills nothing, and the specs run `src/` and never touch `dist/`, so this is the only thing that would notice — see [compatibility](./docs/contributor/compatibility.md). The `types` field must name the **barrel's** declaration (`dist/index.d.ts`), not a module's: the build emits one `.d.ts` per source module, and `dist/maxrects-packer.d.ts` — the declaration of `src/maxrects-packer.ts` — only exports `MaxRectsPacker`, `PACKING_LOGIC` and `IOption` among the eleven documented exports (plus the two `EDGE_*` constants), which left six of them unimportable from TypeScript until the type fixture landed. The emitted declarations carry explicit `.js` extensions — `scripts/fix-declaration-extensions.mjs` writes them from `postbuild` — because the package is `"type": "module"`: without them, a `node16`/`nodenext` consumer with `skipLibCheck: false` fails inside this package's own `.d.ts` files with TS2834/TS2835. `bundler` and the historical `node10` never needed them, and the extension costs them nothing: `node10` (and TypeScript 4.6 with it) resolves `./x.js` to `./x.d.ts` as well. Measured after the rewrite: `bundler`, `node16` and `nodenext`, each with `skipLibCheck: false`, and `node10`/CommonJS all compile the type fixture. `npm run verify:package` compiles it under `bundler`, `node16` and `nodenext`, so those three are gated; `node10` is measured by hand, since the extension only ever had to be `.js` (not the `.mjs` TS2835 suggests) for it to keep working. The `exports` map is what a modern resolver reads, and it is **exhaustive**: `import` gets `dist/maxrects-packer.mjs` + `dist/index.d.ts`, `require` gets `dist/maxrects-packer.cjs` + `dist/index.d.cts`, and only `./package.json` is reachable besides `.` — so deep imports (`maxrects-packer/dist/...`, `maxrects-packer/src/...`) now fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. That sealing is the documented breaking change of 3.0.0 ([troubleshooting](./docs/user/troubleshooting.md#a-deep-import-fails-with-err-package-path-not-exported)). Because the package is `"type": "module"` every `.d.ts` is ESM-flavoured, so the require branch needs the CommonJS `.d.cts` set that `scripts/build-cjs-declarations.mjs` writes from `postbuild`; its relative specifiers are the `.cjs` twins of the `.js` ones above, and `verify:package` compiles a `.cts` fixture under `node16` and `nodenext` with `skipLibCheck: false` so the branch cannot regress unnoticed (that was issue #98's gap: the old gate only had an ESM fixture). - Published content is decided by the `files` allowlist in `package.json`: `dist` + `src` + `assets/favicon.ico` + `CHANGELOG.md` + `tsconfig*.json` + `typedoc.json` (measured `npm pack`: ~75kB / 35 files — the byte count drifts slightly between builds, the file count does not; the published 2.7.4 tarball was 67.0kB / 33 files). That file list is **pinned**: `PUBLISHED_FILES` in `scripts/verify-package.mjs` is compared against `npm pack --dry-run --json`, so an entry added to the allowlist (the site's `docs/` tree, `assets/*.png`) or dropped from it (`src/`, a tsconfig) fails `npm run verify:package` by name. The differences from 2.7.4 are all deliberate: `eslint.config.js`, `.eslintrc.json` and `.github/workflows/node.js.yml` are no longer published, `dist/maxrects-packer.cjs` is new, and `UPGRADE_SUMMARY.md` was deleted as an obsolete dependency-upgrade log. There is **no `.npmignore`**, so npm falls back to `.gitignore` (which lists `dist`/`lib`), but `files` wins and `dist` still ships. `assets/*.png` is not in the allowlist, and publishing it would not change what the npm page shows: npm does not resolve a relative README path against the package (checked on the page on 2026-10-10), which is why the README heading uses an absolute URL. Adding the whole `assets` directory would grow the tarball from ~66kB to ~530kB (`assets/` is 468kB, mostly uncompressed PNG). `clean` removes both `dist` and the legacy `lib`. - The `resolved` fields in `package-lock.json` must point at `registry.npmjs.org`: this machine has a China-based npm mirror configured at the user level, and regenerating the lockfile through it rewrites every absolute tarball URL to the mirror host, which breaks `npm ci` for everyone else (this was P1 in the PR #68 review). After regenerating, confirm `grep -c mirrors.cloud.tencent.com package-lock.json` is 0; if needed, `sed` the host name back to npmjs (paths and integrity are unchanged) and re-verify with an **empty cache**: `npm ci --registry=https://registry.npmjs.org --cache `. - The rollup config that builds `min.js` (`sourcemap: false`) prints `Rollup 'sourcemap' option must be set to generate source maps` — known, harmless noise. - `cz-conventional-changelog` is the last stale dependency (it only powers interactive commits via commitizen); it can be swapped for commitlint or dropped entirely. ## Deferred work Findings that need a deliberate change later — unreachable or redundant code, behaviour questions, and the documentation migration — are recorded in [docs/plans/deferred-work.md](./docs/plans/deferred-work.md). Designs and specifications go to `docs/spec/`, named `YYYY-MM-DD-.md`; both directories are tracked but excluded from the site. None of them is fixed on the spot: each is a behaviour decision with its own PR. Keep day-to-day records in that file rather than growing this one. This file describes how the repository works and what to watch out for; it is not a log.