Skip to content

fix: make clone() copy its rects instead of sharing them - #79

Merged
soimy merged 4 commits into
masterfrom
refactor/clone-semantics
Sep 29, 2026
Merged

soimy merged 4 commits into
masterfrom
refactor/clone-semantics

Conversation

@soimy

@soimy soimy commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Closes the last code item in DEFERRED_WORK.md: clone() promised a copy and handed out aliases.

The defect, measured on both classes

clone.rects[i] === bin.rects[i]              [true, true, true, true]
clone.rects[0].width = 999 -> source width   999          (MaxRectsBin and OversizedElementBin)
clone.tag   (source tag "one")               undefined

Mutating a rect through a clone changed the source bin's rect, after which the source's own rects described a rect the bin had never placed. MaxRectsBin.clone() additionally dropped tag and data, so the copy was not even faithful on metadata. The only caller inside the library is addArray()'s tag-grouping probe, which specifically wants a throwaway bin that cannot reach the one it probes.

The fix

  • Rectangle.Clone(rect) (new static) copies a rect without going through its setters: prototype and every own property are kept, so a Rectangle stays a Rectangle, a custom rect class keeps its identity and its extra fields, and a plain {width, height} object stays plain. The copy is shallow — the object in rect.data is shared. Documented on the static, on both clone()s and in AGENTS.md.
  • MaxRectsBin.clone() re-packs copies and carries tag/data; OversizedElementBin.clone() copies its rect and carries tag.
  • The new public static is pinned in the verify:package type fixture: narrowing it back to (rect: IRectangle): IRectangle fails the gate, with the fixture line as the only error.

Making sure the replay stayed faithful

The copy is still built by re-packing rather than by copying internal state, and that replay now feeds the copies. A replayed input that differed from the original — a rect whose dimensions rotation had already swapped, for instance — would land somewhere else. Measured before writing any specs: identical placements on a rotation-heavy fixture and on 80 seeded bins (30 rects each, allowRotation off and on, 0 divergences). That is not left as a claim: test/maxrects-bin.spec.js pins one rotated fixture and sweeps the 80 seeded bins.

Review round 1 — two P1s, both real, both fixed (e0264d9)

A tagged bin cloned empty. With exclusiveTag in play the copy was built before it carried the source's tag, so place() refused every rect. Measured on the previous commit: source rects 1, clone rects 0; measured on HEAD~1 too, so it predates this branch. Setting the tag first breaks the mirror case (a bin tagged after it was filled holds untagged rects, and the gate refuses those — an existing spec caught it), so the replay now runs with the gate off and options/tag/data are restored from the source afterwards.

A rect class with ECMAScript #private fields could not be copied. Object.assign cannot reach a private slot: TypeError: Cannot read private member #width from an object whose class did not declare it. Rectangle.Clone now uses the rect's own clone() when its class has one, and otherwise reports the limitation where the copy is made, naming the fix. Copying genuinely cannot work for unreachable state (structuredClone drops the prototype), and falling back to sharing would be the aliasing this PR removes.

Third case, found while measuring: the replay silently dropped a rect it could not place — resize a placed 100x100 rect to 4000 in a 256x128 bin and the copy came back with fewer rects than the original, silently. That throws now.

All four new assertions fail on the previous commit with those exact symptoms. Tests 109 passed / 2 skipped; coverage still 100% (452/452, 344/344, 76/76, 407/407).

Verified

  • Old implementation, new assertions: 6 failures — identity on both classes, cross-bin mutation, custom-class object identity, and the missing tag (expected Rectangle{…} not to be Rectangle{…}, expected 999 to be 100, expected undefined to be 'one').
  • New implementation: clone.rects[0] is not the source object, the source's rects are untouched in both directions, adding to one bin does not grow the other, custom class instances keep prototype + extra fields, tag/data are carried over, and the copied bin reproduces the source placements.
  • npm test 109 passed / 2 skipped (was 98); cover still 100% on statements, branches, functions and lines (452/452, 344/344, 76/76, 407/407, no gap left in any file); lint 0/0, format:check, typecheck and verify:package (bundler, node16, nodenext) green.

Behaviour change to note

Any consumer that relied on mutating a rect through one bin to reach the other loses that path — that aliasing is the defect. Read-only use, such as cloning a bin to test whether something still fits, sees the same placements as before (measured). I typed this fix: rather than fix! because no signature changed and the read-only results are identical; retitle with ! if you would rather have the release treat it as breaking.

`MaxRectsBin.clone()` and `OversizedElementBin.clone()` returned a bin holding the **same** rect
objects as the source, so the two bins were never really separate: `clone.rects[0].width = 999`
changed the source bin's rect too, and the source's `rects` then described a rect the bin had not
placed. Measured on both classes before this change:

    clone.rects[i] === bin.rects[i]                [true, true, true, true]
    clone.rects[0].width = 999 -> source width     999
    clone.tag (source tag "one")                   undefined

`MaxRectsBin` also dropped the source's `tag` and `data`, so a clone was not even a faithful copy of
the bin's metadata. A copy operation that hands out aliases is what made this surprising; the only
in-library caller is `addArray()`'s tag-grouping probe, which itself wants a bin that cannot touch
the one it is probing.

- `Rectangle.Clone(rect)` (new static) copies a rect without going through its setters: the prototype
  and every own property are kept, so a `Rectangle` stays a `Rectangle`, a custom rect class keeps its
  identity and extra fields, and a plain `{width, height}` object stays plain. The copy is shallow, so
  the object in `rect.data` is shared — documented on the static, on both `clone()`s and in
  `AGENTS.md` (invariant 8).
- `MaxRectsBin.clone()` re-packs the copies and carries `tag`/`data` over;
  `OversizedElementBin.clone()` copies its rect and carries `tag` over.
- The published declaration of the new static is pinned by the type fixture in
  `scripts/verify-package.mjs`; narrowing `Clone` back to `(rect: IRectangle): IRectangle` fails the
  gate with the fixture line as the only error.

The copy is still produced by re-packing rather than by a state copy, and that replay had to stay
faithful: with the rects now being copies, a replayed input that differed from the original — a rect
whose dimensions rotation had already swapped, say — would land elsewhere. Measured before writing
the specs: identical placements on a rotation-heavy fixture and on 80 seeded bins (30 rects each, both
rotation modes). `test/maxrects-bin.spec.js` now pins one rotated fixture and sweeps the 80 seeded
bins, so the property is a gate rather than a measurement.

Behaviour change to call out: code that relied on mutating a rect through one bin to reach the other
stops working. Read-only uses (cloning a bin to test whether something still fits) see the same
placements as before. Verified against the old implementation: the six new/rewritten assertions fail
there — identity on both classes, cross-bin mutation, custom-class identity, and the missing `tag`.

Tests 98 -> 105 (2 skipped), coverage still 100% (441/441 statements, 340/340 branches, 76/76
functions, 397/397 lines).
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[High risk]

Do not merge until repacking a cloned bin leaves the source bin unchanged.

Findings

  1. P1 Cloned rectangles change originals ▶
Fix with agent prompt
### Issue 1
src/geom/Rectangle.ts:86
If a custom rectangle’s `clone()` returns independent coordinate accessors, this line replaces them with accessors tied to the original rectangle. Repacking the cloned bin can then move a rectangle in the source bin, breaking clone isolation. A valid custom clone with non-configurable accessors instead throws when those properties are redefined.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR copies rectangles when cloning bins and lets packing skip bins that cannot be copied. In src/geom/Rectangle.ts, copying a custom rectangle can replace its independent coordinate accessors with accessors tied to the original. Repacking the cloned bin then moves a rectangle in the source bin. This needs to be fixed before merging.

Reviews (3) · Last reviewed commit: "fix: keep an uncopyable bin from failing..."

Comment thread src/maxrects-bin.ts
Comment thread src/geom/Rectangle.ts Outdated
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Bin cloning overwrites independent custom rectangle accessors ▶

    • Bug
      • A cloned bin can mutate rectangles in its source bin during repack. A custom clone with nonconfigurable own accessors instead fails to clone.
    • Cause
      • Object.defineProperties(copy, Object.getOwnPropertyDescriptors(rect)) at src/geom/Rectangle.ts:86 replaces the independent accessors returned by rect.clone() with accessor functions that close over the source rectangle’s state.
    • Fix
      • Preserve the clone result’s own accessors; copy only properties that need restoring without replacing its independent coordinate accessors.

Two failures from the review of the previous commit, both reproduced first.

**A tagged bin cloned empty.** With `exclusiveTag` in play, `place()` refuses a rect whose tag does
not match the bin's, and the copy was built before it had the source's tag — so cloning a tagged bin
handed back a bin with no rects at all. Measured on the previous commit: a 1024x1024 bin holding one
tagged rect reports `source rects 1, clone rects 0`. Also measured on `HEAD~1`, i.e. before this
branch: the same, so the ordering has been wrong for as long as the tag gate has been in `place()`.

The obvious repair — put the tag on the copy first — breaks the mirror case, which the existing
"copies the tag and the data" spec covers: a bin tagged *after* it was filled holds untagged rects,
and the gate then refuses those. So the replay now runs with the tag gate **off**
(`{...this.options, exclusiveTag: false}`) and `options`, `tag` and `data` are restored from the
source afterwards. The source already settled which rects it accepts; re-deciding that while copying
is what dropped them. Both orderings are pinned by specs.

**A rect class with ECMAScript `#private` fields could not be copied.** Such a rect can be packed —
`add()` stores `x`/`y`/`rot` as own properties and reads `width`/`height` through getters — but
`Object.assign` cannot reach a private slot, so the copy threw
`TypeError: Cannot read private member #width from an object whose class did not declare it` deep
inside the replay. `Rectangle.Clone` now uses the rect's own `clone()` method when its class provides
one, and otherwise reports the limitation where the copy is made, naming the fix, instead of leaving
a TypeError to surface from a bin that is only trying to place it. Sharing the rect instead of
copying it was the other option and is exactly the aliasing this branch removed.

**Third finding, from measuring the above:** the replay silently dropped a rect it could not place.
A placed rect the caller resizes out of its bin (a 100x100 rect taken to 4000 wide in a 256x128 bin)
made `clone()` hand back a bin holding fewer rects than the original, with nothing said. That now
throws and asks for a repack.

All four new assertions fail on the previous commit with the symptoms above (`expected [] to have a
length of 1 but got +0`, the private-member TypeError, the unmatched error message, and the missing
throw); all pass here. Tests 105 -> 109 (2 skipped), coverage still 100% (452/452 statements, 344/344
branches, 76/76 functions, 407/407 lines).
Comment thread src/geom/Rectangle.ts Outdated
Comment thread src/maxrects-bin.ts

@soimy soimy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复核最新 HEAD e0264d98a4c02b5d90310e1f790a1d5e3fcbd6d7 后,我认为前一轮两个已知 P1 都已经针对其具体 reproducer 修复,但目前仍有 1 个复制语义上的阻塞点,建议修复后再合并。

Blocking: Rectangle.Clone() 还没有完整兑现“保留 every own property / custom rect extra fields”的契约

当前实现:

const copy = Object.assign(Object.create(Object.getPrototypeOf(rect)), rect) as T;

只会复制 enumerable own properties,并不会复制所有 own properties。这样会产生两个问题:

  1. non-enumerable own state 会被静默丢失。
    一个合法的自定义 IRectangle 如果用 non-enumerable own property 保存额外字段,clone 后这些字段不会存在;如果 width/height 的 backing fields 本身是 non-enumerable,copy.width 甚至可能变成 undefined,随后被误报成 “rect can no longer place”。

  2. 当前 private-state 防护只探测 width/height。
    Rectangle.Clone() 只在这里主动读取:

    void copy.width;
    void copy.height;

    因此一个可以正常 add() 的类,如果 width/height 是普通字段,但 x/y、rot 或 data 由 ECMAScript #private 字段支持,就会通过这里,然后在 MaxRectsBin.clone() replay 时以原生 private-brand TypeError 崩掉,而不是得到这里承诺的 actionable cloning-contract error。

也就是说,Greptile 原来的 private-state finding 对 #width/#height 这个具体 reproducer 已经修复,但一般性 private-state / own-property 复制问题还没有完全封住。

建议的最小修复:

  • 用 Object.getOwnPropertyDescriptors() + Object.defineProperties() 真正复制全部 own properties,而不是 Object.assign()。
  • 增加一个 non-enumerable backing/extra-field 的回归测试。
  • 增加一个 private x/y(无自定义 clone())的回归测试,确保失败统一变成清晰的 “give its class a clone() method” 语义,而不是底层 TypeError。

已确认通过

  • exclusiveTag replay 现在关闭 gate,随后恢复 source 的 options/tag/data,覆盖了“先打 tag”和“后打 tag”两种场景。
  • replay 无法重新放置被外部修改到不合法尺寸的 rect 时会显式 throw,不再静默返回不完整 clone。
  • rotated fixture + 80 seeded bins 覆盖了 re-pack clone 的 placement 一致性。
  • 最新 CI 在 Node 20 / 22 / 24 全绿;每组都执行并通过了 lint、format、typecheck、coverage 和 verify:package。
  • verify:package 实际覆盖 bundler / node16 / nodenext 三种 TypeScript resolution mode。

Non-blocking notes

  • Coverage workflow 的 threshold 是 99/98/99/99;绿色 CI 能证明 coverage gate 通过,但本身并不机械证明 PR 描述中的精确 100% 计数。
  • verify:package 目前验证了 Rectangle.Clone(new Rectangle()) 的发布类型,但没有用带额外字段的 subtype 明确钉住 Clone<T>(rect: T): T 的泛型保留;可作为后续加强,不必单独阻塞。

修掉上面的 own-property/private-state 边界并补两条小测试后,我没有看到其他真实的合并阻塞项。

Review of e0264d9 found the copy contract was only half kept: `Object.assign` copies *enumerable* own
properties, so anything a rect keeps non-enumerable was silently dropped. Measured on that commit, a
rect class with non-enumerable `width`/`height` did not merely lose fields — the copy read
`width: undefined`, and cloning its bin blew up with `RangeError: Maximum call stack size exceeded`
after 162s of growth probing, i.e. the review's "misreported as a rect it can no longer place" case was
in practice a stack overflow. The private-state guard was also size-only: a class with plain
`width`/`height` and `#private` `x`/`y` passed the guard and then died inside the replay with
`Cannot write private member #x to an object whose class did not declare it`, instead of the
actionable error this branch promises.

- The copy now takes `Object.getOwnPropertyDescriptors()` and applies them with
  `Object.defineProperties()`, so non-enumerable fields — extra ones and the backing fields behind
  `width`/`height` — arrive intact.
- The guard reads every field the replay touches (`width`, `height`, `x`, `y`, `rot`, `data`), not just
  the size, so the private-state failure is the documented "give its class a clone() method" one.
- A class with its own `clone()` is now only the *base* of the copy: the original's own property
  descriptors are applied on top. Review also flagged that a `clone()` rebuilding just the dimensions
  (the tempting implementation) dropped the payload, the tag and everything added since construction —
  `clone.rects[0].data` came back `undefined`, and the same applied to an `OversizedElementBin`'s
  payload. That is fixed by the same descriptor pass.

Verified against e0264d9 with the three new specs, which fail there with exactly the symptoms above:
`copies non-enumerable own properties` (RangeError), `reports a rect whose placement is out of reach`
(private-brand TypeError instead of the message), `keeps what a narrow custom clone() leaves out`
(`expected undefined to be { sheet: 'atlas-1' }`).

Also pinned the generic in the published types, as the review suggested: the `verify:package` fixture
now copies a `SheetRect extends Rectangle` carrying an extra field, and narrowing `Clone` to
`(rect: IRectangle): IRectangle` fails the gate with two errors before the fix is restored.

Tests 109 -> 112 (2 skipped), coverage still 100% (455/455 statements, 344/344 branches, 76/76
functions, 411/411 lines).
@soimy

soimy commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

Blocking item fixed in c8decb2, both parts, plus the two non-blocking notes.

Own-property contract. Object.assign is gone: the copy now takes Object.getOwnPropertyDescriptors(rect) and applies it with Object.defineProperties(), so non-enumerable own state survives. The non-enumerable case turned out to be worse than a wrong message — a rect class with non-enumerable width/height made the copy read width: undefined, and cloning its bin ended in RangeError: Maximum call stack size exceeded after 162s of growth probing, i.e. the misreport you predicted was a stack overflow in practice. New spec copies non-enumerable own properties pins the fix (and runs in single-digit ms).

Private-state guard. It now reads every field the replay touches — width, height, x, y, rot, data — not just the size. New spec reports a rect whose placement is out of reach uses plain width/height with #private x/y and no clone(); on e0264d9 that failed with Cannot write private member #x to an object whose class did not declare it, and now yields the documented "give its class a clone() method" error.

Non-blocking note 2 (generic preservation in the published types) is done too: the verify:package fixture now copies a SheetRect extends Rectangle carrying an extra field and reads it, so the generic is load-bearing. Verified teeth: narrowing Clone back to (rect: IRectangle): IRectangle fails the gate with two errors.

On note 1 (coverage thresholds) you are right that green CI does not prove the exact numbers. They come from a local npm run cover plus a direct read of test/coverage/coverage-final.json and lcov.info: statements 455/455, branches 344/344, functions 76/76, lines 411/411, and no statement or branch left unexecuted in any src file. The thresholds stay 99/98/99/99 as you decided; I am not proposing to change them here.

One extra case came out of this round, from Greptile's second finding rather than from yours: with a placed rect resized beyond its bin, MaxRectsBin.clone() throws (as you confirmed correct), and in non-exclusive tag mode addArray() calls clone() inside its probe, so that throw now aborts the whole addArray call instead of letting another bin take the group. Reachable only when a bin is already inconsistent, but it is a real behaviour change of this branch. Guarding the probe is three lines; I have not done it because your review endorsed the throw and did not ask for it — say the word and it goes in.

@soimy soimy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审最新 HEAD c8decb258bd145fc8af04a76c62f85367ea61257 后,上次 review 提出的阻塞项已经修复到位:

  • Rectangle.Clone() 已改为用 Object.getOwnPropertyDescriptors() + Object.defineProperties() 复制全部 own properties,包括 non-enumerable state。
  • 新增测试覆盖了 non-enumerable backing/extra fields。
  • private x/y 场景现在会得到明确的 give its class a clone() method 错误,而不是底层 private-brand TypeError。
  • 自定义 clone() 现在只负责提供具有正确 private brand 的基础对象,原 rect 的 payload / placement / extra fields 会重新覆盖,因此 Greptile 的 “custom clones lose metadata” finding 已解决。
  • verify:package 已用 SheetRect extends Rectangle 的额外字段真正钉住 Clone<T>(rect: T): T 的泛型保持。
  • 最新 Node 20 / 22 / 24 CI 全部通过 lint、format、typecheck、coverage 和 verify:package。

不过当前仍有 1 个真实阻塞风险:

Blocking: addArray() 的 clone probe 会把单个不可克隆 bin 的错误升级成整个 packing 操作失败

在 non-exclusive tag 路径中:

let targetBin = this.bins.slice(this._currentBinIndex).find((bin) => {
    let testBin = bin.clone();
    ...
});

现在 MaxRectsBin.clone() 有意会在无法得到完整副本时 throw。这对显式调用 bin.clone() 是合理的,但这里的 clone 只是 addArray() 用来判断“当前 tag group 是否适合这个 bin”的内部探针。

因此只要某个候选 bin 无法 clone,异常就会直接中断整个 addArray(),后面的 bin 不再被尝试,即使其中某个 bin 完全能够容纳当前 group。

这不只覆盖“调用者把已放置 rect resize 到超出 bin”的不一致状态。此次 PR 新增的 clone contract 本身也能触发:一个带 #private 状态、没有自定义 clone() 的 rect 仍可以正常被 add() 接受,但其所在 bin 无法 clone;之后走 non-exclusive-tag addArray() 时,这个 bin 就会把 clone API 的限制泄漏成整个 addArray() 的限制。

建议的最小修复: 在 probe 位置捕获 bin.clone() 的失败,并把该 bin 当作“当前 group 不适合这个候选 bin”,返回 false 让 .find() 继续检查后面的 bin;如果没有现有 bin 可用,再沿现有逻辑创建新 bin。

建议同时补一条回归测试:

  • 第一个候选 bin 的 clone() 会失败;
  • 第二个 bin 能容纳当前 tag group;
  • addArray() 不 throw;
  • group 最终进入第二个 bin。

修掉这一点并让 CI 再绿一次后,我没有看到其他需要继续阻塞合并的问题。

The grouping probe in non-exclusive tag mode (`addArray()`) copies each candidate bin. `clone()` now
throws rather than returning an incomplete copy, and the probe had no handler, so one uncopyable bin
aborted the entire call: the later bins were never tried even when one could hold the group, and the
rects after the current group were left unpacked.

Reachable in two ways, both real:

- a placed rect the caller resized out of its bin, which `clone()` reports on purpose;
- a rect class whose state a copy cannot reach (an ECMAScript `#private` field, no `clone()` of its
  own): such a rect packs fine through `add()`, but its bin cannot be copied, so the clone API's
  limitation leaked into the packing call.

The probe now treats a bin that cannot be copied as one the group does not fit — `catch { return
false }` — so the search continues and the existing "no bin fits, open a new one" path handles the
rest. Calling `clone()` directly still throws, with its own message.

Spec `does not let an uncopyable bin abort the tag grouping` covers the reported shape: the first bin
holds a rect whose class hides its size behind `#private` fields, the second bin has room, and the
group has to land there. On the previous commit it fails with
`Error: Rectangle.Clone(): the rect keeps its state out of reach of a copy …` escaping `addArray()`;
here the call completes, the packer keeps two bins and the group's two rects join the second one.

Tests 112 -> 113 (2 skipped), coverage still 100% (457/457 statements, 344/344 branches, 76/76
functions, 413/413 lines).
@soimy

soimy commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 9d6ad95, exactly as suggested: the probe catches the clone() failure and returns false, so the search moves on and the existing "no bin fits, open a new one" path handles the rest. bin.clone() called directly still throws with its own message.

Your second reachability condition was the one I had underweighted when I asked about this last round: a rect class with #private state and no clone() packs normally through add(), so its bin is uncopyable without any caller mistake, and the probe turned that into a failed addArray(). The spec covers the shape you asked for — first bin uncopyable, second bin with room, addArray() must not throw and the group must land in the second bin:

packer.add(new PrivateRect(900, 900));            // bin 1: #private size, no clone(); bin.clone() throws
packer.add(new Rectangle(200, 200));              // opens bin 2
expect(() => packer.addArray([300x300 tagged, 300x300 tagged])).not.toThrow();
bins 2 · bin 1 has 1 rect · bin 2 has 3

On 9d6ad95's parent it fails with the clone error escaping addArray():
Error: Rectangle.Clone(): the rect keeps its state out of reach of a copy (an ECMAScript #private field, say) — give its class a clone() method.

Tests 113 passed / 2 skipped, coverage still 100% (457/457 statements, 344/344 branches, 76/76 functions, 413/413 lines), and AGENTS.md invariant 2 records the probe rule so it does not get "simplified" back. CI is running on the new head.

@soimy soimy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审最新 HEAD 9d6ad9525287e1f137ecdb002205edb7ae4a8188 后,上次剩余的阻塞项已经正确修复,目前未发现新的合并阻塞风险。

最终结论:建议合并

本轮增量只涉及 addArray() 的 speculative clone probe,处理方式是正确的:

let testBin: Bin<T>;
try {
    testBin = bin.clone();
} catch {
    return false;
}

这个边界保持得很好:

  • 直接调用 bin.clone() 时,无法产生完整副本仍然会明确 throw,不会重新引入静默丢 rect 的问题。
  • 只有当 addArray() 把 clone 用作候选 bin 的内部 fit probe 时,clone failure 才被解释为“这个 bin 不适合当前 group”,随后继续搜索后面的 bin。
  • catch 范围只包住 bin.clone(),不会吞掉后续真实 packing 路径中的异常。

新增回归测试也覆盖了上一轮要求的关键场景:

  • 第一个 bin 持有 #private state、无自定义 clone() 的 rect,直接 bin.clone() 会失败;
  • 第二个 bin 有足够空间;
  • non-exclusive-tag addArray() 不再 throw;
  • 当前 group 最终进入第二个 bin;
  • 第一个 bin 保持原状态。

这个测试比只验证“失败后新开一个 bin”更强,因为它实际证明了候选搜索会继续到后续 bin。

CI / 回归状态

最新 HEAD 的 GitHub Actions 已全部完成,Node 20.x / 22.x / 24.x 三组均为 success,每组都通过:

  • npm ci
  • npm run lint
  • npm run format:check
  • npm run typecheck
  • npm run cover
  • npm run verify:package

此前几轮确认的其他风险也没有被本轮重新打开:own-property descriptor copy、non-enumerable state、private-state cloning contract、custom clone() metadata preservation、exclusiveTag replay、rotation placement replay,以及 bundler / node16 / nodenext 发布类型验证都保持成立。

基于当前 9d6ad95,我没有看到还需要继续阻塞合并的问题。

@soimy
soimy merged commit e884d35 into master Sep 29, 2026
4 checks passed
@soimy
soimy deleted the refactor/clone-semantics branch September 29, 2026 14:21
Comment thread src/geom/Rectangle.ts
typeof copier === "function" ? copier.call(rect) : (Object.create(Object.getPrototypeOf(rect)) as T);
// Descriptors rather than `Object.assign`: a rect may keep extra fields, or the backing fields
// behind `width`/`height`, non-enumerable, and assignment would drop those silently.
Object.defineProperties(copy, Object.getOwnPropertyDescriptors(rect));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cloned rectangles change originals

If a custom rectangle’s clone() returns independent coordinate accessors, this line replaces them with accessors tied to the original rectangle. Repacking the cloned bin can then move a rectangle in the source bin, breaking clone isolation. A valid custom clone with non-configurable accessors instead throws when those properties are redefined.

Artifacts

Custom rectangle and bin clone reproduction script

  • The executed script loads Rectangle.Clone from either revision and runs identical bin-clone, repack, and nonconfigurable-accessor checks, showing the exact test input.

Bin clone behavior before the change

  • Running the script with Rectangle.Clone from e0264d9 left the original at y=0 and cloned successfully with nonconfigurable accessors, establishing the baseline.

Bin clone behavior with the current source

  • Running the same script with the current Rectangle.Clone moved the original to y=3 and threw on nonconfigurable accessors, confirming the defect.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/geom/Rectangle.ts
Line: 86

Comment:
**Cloned rectangles change originals**

If a custom rectangle’s `clone()` returns independent coordinate accessors, this line replaces them with accessors tied to the original rectangle. Repacking the cloned bin can then move a rectangle in the source bin, breaking clone isolation. A valid custom clone with non-configurable accessors instead throws when those properties are redefined.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@soimy

soimy commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

The Comments Outside Diff finding here — "Bin cloning overwrites independent custom rectangle accessors" — is confirmed and fixed in #80 (fix: let a custom clone() keep the properties it defines), branched from the merge commit e884d35.

Reproduced against that commit before touching anything:

copy's x accessor is the source's own?     true
copy.x = 999 -> source x                   999        # a cloned bin's repack mutating the source again
bin.clone() outcome                        TypeError: Cannot redefine property: x

Rectangle.Clone no longer merges the source's descriptors over a custom clone() result unconditionally: what clone() defines wins, and only its missing data properties are filled in from the original. The metadata fix from c8decb2 survives (the non-enumerable and narrow-clone() specs still pass), the copy keeps the accessors its class built, and a property the copy declares non-configurable is never redefined.

Three specs pin it, all failing on e884d35 with the symptoms above; the residual case — an own accessor closing over the source on a rect with no clone() — is documented in the JSDoc and AGENTS.md rather than guessed at, since the two shapes cannot be told apart from outside the class.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant