From 0e12d4b7d621cf2ad067944db9cad5829af5efcf Mon Sep 17 00:00:00 2001 From: Jordan Baker Date: Sun, 4 Oct 2026 09:06:06 -0600 Subject: [PATCH 1/5] fix: correct date example and probe dist debug behavior --- README.md | 9 ++++++--- package.json | 2 +- scripts/check-dist-debug.mjs | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 scripts/check-dist-debug.mjs diff --git a/README.md b/README.md index c54f256..77a23c5 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ Since intervals work with numbers, you can use timestamps for date-based interva ```js const schedule = new IntervalTree() -// Add time slots (using timestamps) +// Store busy time (using timestamps) const start = new Date('2024-01-01T09:00:00').getTime() const end = new Date('2024-01-01T10:00:00').getTime() schedule.addInterval(start, end, 'Morning meeting') @@ -290,8 +290,11 @@ const conflicts = schedule.searchPoint(when) // Find available slots const dayStart = new Date('2024-01-01T08:00:00').getTime() +const dayEnd = new Date('2024-01-01T17:00:00').getTime() +const freeSlots = IntervalTree.fromTuples([[dayStart, dayEnd]]).difference(schedule) const minDuration = 60 * 60 * 1000 // 1 hour in milliseconds -const available = schedule.findOneByLengthStartingAt(minDuration, dayStart) +const available = freeSlots.findOneByLengthStartingAt(minDuration, dayStart) +// Returns [08:00, 09:00), before the morning meeting. ``` ## API Reference @@ -673,7 +676,7 @@ This library uses **half-open intervals** `[start, end)` where: **Why half-open intervals?** 1. **Length calculation**: Just `end - start` (no off-by-one errors) 2. **Adjacent intervals**: `[1, 5)` and `[5, 10)` don't overlap -3. **Empty intervals**: `[5, 5)` is naturally empty +3. **Stored intervals must have positive length**: constructing `[5, 5)` throws. Empty ranges passed to `chop` or `chopAll` are no-ops. 4. **Consistency**: Matches JavaScript conventions (`Array.slice`, `substring`, etc.) This follows the recommendation from Edsger W. Dijkstra's 1982 note on interval notation and is used by most programming languages and CS literature. diff --git a/package.json b/package.json index 88657b0..920b3a1 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "packageManager": "pnpm@12.4.0", "scripts": { "build": "npx tsup", - "postbuild": "node -e \"const fs=require('fs');for(const f of ['dist/index.js','dist/index.cjs']){const s=fs.readFileSync(f,'utf8');if(/DEBUG = true/.test(s)||!s.includes('INTERVALTREE_DEBUG'))throw new Error(f+': debug flag frozen on or missing')}\"", + "postbuild": "node scripts/check-dist-debug.mjs", "clean": "rimraf dist", "prebuild": "rimraf dist", "prepare": "npm run build", diff --git a/scripts/check-dist-debug.mjs b/scripts/check-dist-debug.mjs new file mode 100644 index 0000000..cf00999 --- /dev/null +++ b/scripts/check-dist-debug.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +// Probe the public behavior in fresh processes with no inherited debug flags. +// This keeps working when the build's flag or local constant is renamed. +const files = process.argv.slice(2) +for (const file of files.length ? files : ['dist/index.js', 'dist/index.cjs']) { + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import assert from 'node:assert/strict' + const { IntervalTree } = await import(${JSON.stringify(pathToFileURL(resolve(file)).href)}) + let checks = 0 + const verify = IntervalTree.prototype.verify + IntervalTree.prototype.verify = function () { + checks++ + return verify.call(this) + } + const tree = new IntervalTree() + tree.addInterval(0, 10) + tree.addInterval(5, 15) + tree.mergeOverlaps() + tree.chop(3, 4) + tree.chopAll([[0, 1], [5, 6], [7, 8], [9, 10]]) + tree.removeEnveloped(0, 3) + tree.clone() + tree.difference(IntervalTree.fromTuples([[12, 13]])) + assert.equal(checks, 0, 'automatic invariant checks must be off by default') + tree.verify() + assert.equal(checks, 1, 'explicit verification must still work') + `], { env: {}, encoding: 'utf8' }) + if (result.error) + throw result.error + if (result.status !== 0) + throw new Error(`${file}: dist debug behavior check failed\n${result.stderr}`) +} From 847b7b9ea1fdcf2b9ac9169dcf42fee7ba1f45c9 Mon Sep 17 00:00:00 2001 From: Jordan Baker Date: Sun, 4 Oct 2026 09:15:03 -0600 Subject: [PATCH 2/5] feat!: return interval queries in canonical order BREAKING CHANGE: findOneByLengthStartingAt now selects by clipped start, then end, matching searchByLengthStartingAt; previously it selected by original start. ArrayIntervalCollection also returns canonical arrays and rejects nonpositive length queries. --- CLAUDE.md | 3 ++ GLOSSARY.md | 34 ++++++++++++++++++++ README.md | 13 ++++++-- docs/design/collection-ops.md | 42 +++++++++++++++++++++++++ docs/design/gaps.md | 32 ++++++++++++------- src/ArrayIntervalCollection.ts | 47 +++++++++++----------------- src/IntervalCollection.spec.ts | 7 ++--- src/IntervalCollection.ts | 8 +++++ src/IntervalTree.ts | 17 +++++----- src/TreeCore.ts | 57 ++++++++++++++++------------------ src/compareIntervals.ts | 7 ++--- src/gaps.ts | 6 ++-- src/modelCheck.test.ts | 34 ++++++++++---------- src/order.spec.ts | 46 +++++++++++++++++++++++++++ src/order.ts | 18 +++++++++++ 15 files changed, 258 insertions(+), 113 deletions(-) create mode 100644 GLOSSARY.md create mode 100644 docs/design/collection-ops.md create mode 100644 src/order.spec.ts create mode 100644 src/order.ts diff --git a/CLAUDE.md b/CLAUDE.md index 68d6fb6..cd55499 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,9 @@ npx eslint . # Run ESLint (uses @antfu/eslint-config) - `IntervalTree.merged` means `mergeOverlaps()` would change nothing: no two intervals overlap or touch. Only `add` clears it and only `mergeOverlaps` sets it. `chopAll` and `difference` use it to skip sorting. - `chop` and `chopAll` treat an empty range (`start === end`) as a no-op and throw on an inverted range. - Among intervals with identical bounds and different `data`, which one `first()`, `last()`, and `mergeOverlaps()` pick is unspecified by contract. +- Array results and iteration have canonical returned-bound order (start, then end). `toSorted()` is an alias for `toArray()`. +- `src/order.ts` owns comparison and start clipping. `findOneByLengthStartingAt` follows the clipped order used by `searchByLengthStartingAt`, with the smallest end winning a clipped-start tie. Its filter receives the stored interval. +- The library is generic. Busy intervals and free slots are caller interpretations; see `GLOSSARY.md`. Experimental `gaps` stays a free function over `IntervalCollection`. ### Testing Approach diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..d635951 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,34 @@ +# Glossary + +intervaltree is a generic interval library. Busy time and free time are caller +interpretations of the same data structure, used in scheduling examples. + +| Term | Meaning | +| --- | --- | +| Interval | An immutable half-open stretch `[start, end)` with optional data. Stored intervals always have `start < end`. | +| Bounds | The inclusive start and exclusive end of an interval or query window. | +| Data | The caller's payload. Together with the bounds, its identity determines interval equality. | +| Window | The bounds passed to a query or mutation. An empty window stores nothing; `chop` and `chopAll` treat it as a no-op. | +| Gap | A maximal part of a window covered by no stored interval. It has no source data. | +| Busy interval | A stored interval interpreted as occupied time by a caller. | +| Free slot | A stored interval interpreted as open time by a caller. | +| Overlap | Sharing at least one point. Touching intervals do not overlap. | +| Touch | One interval's end equals the other's start. | +| Merge | Replacing a run of overlapping or touching intervals with one interval. | +| Chop | Cutting a window out of every stored interval, keeping the surviving fragments and their data. | +| Envelop | A window envelops an interval when the interval lies wholly inside it. | +| Contain | An interval contains a point `p` when `start <= p < end`. | +| Canonical order | Ascending start, then end, of returned bounds. Order among identical bounds with different data is unspecified. | +| Merged tree | A tree known to have no overlapping or touching intervals. Removing or chopping intervals preserves that property. | +| Record union | All distinct intervals from two collections, retaining their data. | +| Range union | Record union followed by merging overlapping and touching intervals. | + +Length queries search stored intervals with enough length remaining after a +point. They never discover gaps or implicitly merge touching intervals. +Call `mergeOverlaps()` first when touching free slots should count as one. +For a busy-time collection, compute a window's complement with `difference` +before asking a length query for free time. + +`gaps` remains an experimental free function in `src/gaps.ts`, operating on +`IntervalCollection`. It belongs outside the storage core; a scheduling +wrapper can be considered if another caller needs booking semantics. diff --git a/README.md b/README.md index 77a23c5..256099c 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,11 @@ const enveloped = tree.searchEnveloped(0, 100) const byLength = tree.searchByLengthStartingAt(3, 5) // Returns: All intervals of length >= 3 starting at position 5 or later -// Find first interval of minimum length — O(log n) with early termination +// Find the first canonical interval of minimum remaining length const first = tree.findOneByLengthStartingAt(3, 5) // Returns: First interval of length >= 3 starting at/after position 5 // If found interval starts before 5, it's adjusted to start at 5 +// Ties on the adjusted start choose the smallest end. // With optional filter function const filtered = tree.findOneByLengthStartingAt(3, 5, iv => iv.data?.priority === 'high') @@ -299,6 +300,14 @@ const available = freeSlots.findOneByLengthStartingAt(minDuration, dayStart) ## API Reference +All array results and iteration use **canonical order**: ascending start, +then end of the returned bounds. Identical bounds with different data are +unordered. `toArray()` is canonical; `toSorted()` remains a compatibility alias. +`findOneByLengthStartingAt(L, t)` selects the first bounds returned by +`searchByLengthStartingAt(L, t)`. Its optional filter receives the original +stored interval. Neither query joins touching intervals; call `mergeOverlaps()` +first if that is the intended interpretation. See [GLOSSARY.md](GLOSSARY.md). + ### IntervalTree **Construction:** @@ -319,7 +328,7 @@ const available = freeSlots.findOneByLengthStartingAt(minDuration, dayStart) - `searchOverlap(start: number, end: number)` - Find all intervals overlapping a range - `searchEnveloped(start: number, end: number)` - Find intervals completely within a range - `searchByLengthStartingAt(length: number, start: number)` - Find intervals by minimum length -- `findOneByLengthStartingAt(minLength: number, startingAt: number, filterFn?: (iv: Interval) => boolean)` - O(log n) first matching interval with optional filter +- `findOneByLengthStartingAt(minLength: number, startingAt: number, filterFn?: (iv: Interval) => boolean)` - First canonical result; O(log n + k) without a filter, where k counts qualifying intervals containing startingAt. A filter can require O(n). **Navigation:** - `first(): Interval | null` - Get the earliest interval by start (O(log n)) diff --git a/docs/design/collection-ops.md b/docs/design/collection-ops.md new file mode 100644 index 0000000..765eaaa --- /dev/null +++ b/docs/design/collection-ops.md @@ -0,0 +1,42 @@ +# Shared collection operations + +Decision: retain a generic interval library and use free functions in a future +`src/collectionOps.ts`. This resolves the saved derived-members design. + +Share a member only when both adapters would execute the same code and a +literal-value test can pin its behavior. Keep explicit delegating members on +each adapter; introduce no base class or new protected public surface. + +The shared functions can cover emptiness, bulk add/remove, interval creation, +tuple/JSON conversion, labeled formatting, iteration, equality, hashing, and +range-union plumbing. Each function depends on only the primitives it reads. +Pin the existing serialized SHA-256 values before moving hash code. The array +adapter should format itself as `ArrayIntervalCollection`, not `IntervalTree`. + +Storage, pruned queries, clipping selection, chopping, merging, and set-algebra +algorithms remain independent in the tree and array oracle. Sharing these +would remove the independence that the model check is intended to test. + +## Answers to the saved design questions + +Empty mutation windows are no-ops; inverted or NaN bounds are errors. This +preserves the explicit empty-range decision made for 2.1.0. A future uniform +query-validation change should apply the same rule, with regression tests and +a breaking release notice: current query methods do not consistently enforce +it. This design resolution does not change their existing error behavior. + +Set-algebra inputs can widen to `IntervalCollection` in a minor release, +while each adapter retains its concrete return type. Implementations must read +public size, sorted intervals, and overlap queries instead of another +adapter's private storage. `map` already accepts a callback and can retain its +concrete return type; it needs no collection-input widening. + +Canonical iteration and result order are now interface guarantees. `toSorted` +stays as a compatibility alias. Hashes and equality retain the existing +insertion-sensitive behavior for identical bounds with different data. + +Implementation is separate from this design decision. Test the shared module +against a minimal primitive stub, adopt it in the oracle while the tree still +has its original code, then adopt it in the tree. Model checks for shared +members must be replaced with properties over independently checked +primitives, rather than comparing the same function against itself. diff --git a/docs/design/gaps.md b/docs/design/gaps.md index edc10cc..22c9b0c 100644 --- a/docs/design/gaps.md +++ b/docs/design/gaps.md @@ -39,8 +39,9 @@ the way out. the range are clipped to it (verified: `[[-5,3],[8,30]]` over `[0,10)` → `[[3,8)]`). -4. **Invalid range.** Decided: `start >= end` throws `start must be < end`, - matching `chop`'s existing assertion, for consistency across the API. +4. **Invalid window.** The spike requires `start < end`. This differs from + `chop` and `chopAll`, where empty windows are no-ops. Keep this experimental + boundary until a public gaps API is proposed. 5. **`minLength` filter parameter?** Decided: no. Callers already have `Array.prototype.filter` on the result, and folding length filtering into @@ -76,17 +77,24 @@ result-tree construction entirely — it returns a plain array. ## Proposed public signature ```ts -function gaps(tree: IntervalTree, start: number, end: number): Interval[] +function gaps(tree: IntervalCollection, start: number, end: number): Interval[] ``` -Free function (not a tree method) is deliberate for the spike, mirroring -`fromTuples`-style ergonomics without adding tree-internal coupling; whether -it should instead be `IntervalTree.prototype.gaps(start, end)` for API -symmetry with `difference`/`chop`/etc. is an open question for the -maintainer, not decided here. +Decision (2026-10-04): keep gaps as a free function over `IntervalCollection`. +The library is generic; busy and free interpretations belong to callers, not +storage types. This helper remains experimental and unexported. See +`GLOSSARY.md` for the terminology. -## Follow-up (out of scope for this spike) +## Length-query duality -If accepted, `searchByLengthStartingAt`'s "first free slot of length L" -logic can likely be re-expressed as a filter over `gaps`, per the plan's -maintenance note. Not attempted here. +For stored busy intervals B and a window W, gaps finds uncovered bounds. +Length queries instead search stored bounds. They agree only after taking the +complement: construct F as W.difference(B), merge F, then compare +`gaps(B, Math.max(t, W.start), W.end).filter(iv => iv.length >= L)` with +`F.searchByLengthStartingAt(L, t)`. This requires t < W.end. + +Neither operation replaces the other on the same collection. Length queries +do not join touching stored intervals; callers should merge them first when +that is their intended interpretation. Focuster stores free slots and chops +bookings out, so its existing length queries are appropriate. A scheduling +wrapper or first-gap convenience API can wait for a concrete second caller. diff --git a/src/ArrayIntervalCollection.ts b/src/ArrayIntervalCollection.ts index 1bce76c..83036c3 100644 --- a/src/ArrayIntervalCollection.ts +++ b/src/ArrayIntervalCollection.ts @@ -1,8 +1,8 @@ import type { IntervalCollection } from './IntervalCollection' import type { IntervalTuple } from './types' import { assert } from './assert' -import { compareIntervals } from './compareIntervals' import { Interval } from './Interval' +import { clipStart, compareIntervals } from './order' import { sha256 } from './sha256' /** @@ -37,21 +37,16 @@ export class ArrayIntervalCollection implements IntervalCollection< startingAt: number, filterFn?: (iv: Interval) => boolean, ): Interval | undefined { - for (const interval of this.toSorted()) { - if (interval.availableLength(startingAt) >= minLength) { - // filterFn sees the stored (unclipped) interval, mirroring TreeCore.ts. - if (filterFn && !filterFn(interval)) - continue - return interval.start < startingAt && interval.end >= startingAt - ? new Interval(startingAt, interval.end, interval.data) - : interval - } - } - return undefined + assert(minLength > 0, 'minLength must be > 0') + // Rank clipped results, but apply the predicate to each stored interval. + return this.toSorted() + .filter(iv => iv.availableLength(startingAt) >= minLength && (!filterFn || filterFn(iv))) + .map(iv => clipStart(iv, startingAt)) + .sort(compareIntervals)[0] } toArray(): Interval[] { - return this.intervals.slice() + return this.toSorted() } toSorted(): Interval[] { @@ -123,16 +118,16 @@ export class ArrayIntervalCollection implements IntervalCollection< } searchPoint(point: number): Interval[] { - return this.intervals.filter(iv => iv.containsPoint(point)) + return this.toSorted().filter(iv => iv.containsPoint(point)) } searchOverlap(start: number, end: number): Interval[] { - return this.intervals.filter(iv => iv.overlapsWith(start, end)) + return this.toSorted().filter(iv => iv.overlapsWith(start, end)) } /** Intervals fully contained within [start, end]: iv.start >= start && iv.end <= end. */ searchEnveloped(start: number, end: number): Interval[] { - return this.intervals.filter(iv => iv.start >= start && iv.end <= end) + return this.toSorted().filter(iv => iv.start >= start && iv.end <= end) } contains(point: number): boolean { @@ -144,25 +139,19 @@ export class ArrayIntervalCollection implements IntervalCollection< } searchByLengthStartingAt(minLength: number, startingAt: number): Interval[] { + assert(minLength > 0, 'minLength must be > 0') return this.intervals - .filter((iv) => { - if (iv.end < startingAt) - return false - - return iv.availableLength(startingAt) >= minLength - }) - .map(iv => iv.start < startingAt ? new Interval(startingAt, iv.end, iv.data) : iv) - .toSorted(compareIntervals) + .filter(iv => iv.availableLength(startingAt) >= minLength) + .map(iv => clipStart(iv, startingAt)) + .sort(compareIntervals) } first(): Interval | null { - const sorted = this.toSorted() - return sorted[0] ?? null + return this.toSorted()[0] ?? null } last(): Interval | null { - const sorted = this.toSorted() - return sorted.at(-1) ?? null + return this.toSorted().at(-1) ?? null } hash(): string { @@ -174,7 +163,7 @@ export class ArrayIntervalCollection implements IntervalCollection< mergeOverlaps(): void { if (this.intervals.length <= 1) return - const sorted = this.intervals.toSorted(compareIntervals) + const sorted = this.toSorted() const merged: Interval[] = [sorted[0]] for (let i = 1; i < sorted.length; i++) { const current = sorted[i] diff --git a/src/IntervalCollection.spec.ts b/src/IntervalCollection.spec.ts index 5c3644e..e60900e 100644 --- a/src/IntervalCollection.spec.ts +++ b/src/IntervalCollection.spec.ts @@ -191,8 +191,7 @@ describe.each(impls)('intervalCollection conformance: $name', ({ make }) => { it('toArray and toSorted', () => { const c = make(base) expect(bounds(c.toSorted())).toEqual(base) - // toArray need not be sorted for every implementation, so sort before comparing. - expect(bounds(c.toArray()).sort((x, y) => x[0] - y[0])).toEqual(base) + expect(bounds(c.toArray())).toEqual(base) }) it('toTuples', () => { @@ -217,13 +216,13 @@ describe.each(impls)('intervalCollection conformance: $name', ({ make }) => { const c = make(base) const seen: Array<[number, number, string | undefined]> = [] c.forEach(iv => seen.push([iv.start, iv.end, iv.data])) - expect(seen.sort((a, b) => a[0] - b[0])).toEqual(base) + expect(seen).toEqual(base) }) it('[Symbol.iterator]', () => { const c = make(base) const collected = [...c] - expect(bounds(collected).sort((a, b) => a[0] - b[0])).toEqual(base) + expect(bounds(collected)).toEqual(base) }) }) diff --git a/src/IntervalCollection.ts b/src/IntervalCollection.ts index f825d49..ce19678 100644 --- a/src/IntervalCollection.ts +++ b/src/IntervalCollection.ts @@ -7,6 +7,10 @@ import type { IntervalTuple } from './types' * so a second implementation (e.g. a flat typed-array tree) can conform and * be swapped in wherever an IntervalCollection is expected. * + * Every array result and iteration is in canonical order of returned bounds: + * ascending start, then end. Identical bounds with different data are unordered. + * `toSorted` is retained as an alias for canonical `toArray`. + * * Left out on purpose: * - `map` — returns `IntervalTree`, tying the return type to one * implementation. @@ -46,6 +50,10 @@ export interface IntervalCollection { searchOverlap: (start: number, end: number) => Interval[] searchEnveloped: (start: number, end: number) => Interval[] searchByLengthStartingAt: (minLength: number, startingAt: number) => Interval[] + /** + * The first canonical length-query result whose stored interval passes filterFn. + * The predicate receives original bounds; the result is clipped to startingAt. + */ findOneByLengthStartingAt: ( minLength: number, startingAt: number, diff --git a/src/IntervalTree.ts b/src/IntervalTree.ts index da8d270..268b851 100644 --- a/src/IntervalTree.ts +++ b/src/IntervalTree.ts @@ -345,13 +345,11 @@ export class IntervalTree implements IntervalCollection { } /** - * Find the first (earliest start) interval with at least `minLength` available - * starting at or after `startingAt`. O(log n) best case via in-order traversal - * with early termination. - * - * If the found interval starts before `startingAt`, the returned interval is - * adjusted to begin at `startingAt`. Among intervals that clip to the same - * start, the one with the earliest original start wins. + * Returns the first canonical result with at least `minLength` remaining + * after `startingAt`. Results starting earlier are clipped to `startingAt`. + * Ties on clipped start choose the smallest end; identical bounds are unordered. + * O(log n + k) without a filter, where k counts qualifying intervals containing + * startingAt. A filter can require scanning O(n) intervals. * * `filterFn` receives the stored interval, not the clipped result. * @@ -370,7 +368,7 @@ export class IntervalTree implements IntervalCollection { } /** - * Searches for intervals with at least `minLength` available starting at or after `startingAt`. + * Searches for stored intervals with at least `minLength` remaining after `startingAt`. * @param minLength The minimum length of the intervals to search for. * @param startingAt The earliest start position to consider. * @returns An array of matching intervals. @@ -397,8 +395,7 @@ export class IntervalTree implements IntervalCollection { } public toSorted(): Interval[] { - // toArray() is in-order by start; sort fully by start then end - return this.toArray().toSorted(compareIntervals) + return this.toArray() } public toTuples(): IntervalTuple[] { diff --git a/src/TreeCore.ts b/src/TreeCore.ts index 1f79940..03ba7ef 100644 --- a/src/TreeCore.ts +++ b/src/TreeCore.ts @@ -1,6 +1,6 @@ +import type { Interval } from './Interval' import { assert } from './assert' -import { compareIntervals } from './compareIntervals' -import { Interval } from './Interval' +import { clipStart, compareIntervals } from './order' interface Aggregates { height: number @@ -236,16 +236,16 @@ class Node { if (point < this.minStart || point > this.maxEnd) return + const left = this._left + if (left && point >= left.minStart) + left.searchPoint(point, result) + for (let i = 0; i < this.values.length; i++) { const v = this.values[i] if (v.start <= point && point < v.end) result.push(v) } - const left = this._left - if (left && point >= left.minStart) - left.searchPoint(point, result) - const right = this._right if (right && point <= right.maxEnd) right.searchPoint(point, result) @@ -404,38 +404,35 @@ class Node { startingAt: number, filterFn?: (iv: Interval) => boolean, ): Interval | undefined { - if (this.maxEnd < startingAt || this.maxLength < minLength) + if (this.maxEnd < startingAt + minLength || this.maxLength < minLength) return undefined - // Check left subtree first (in-order — earliest start wins) - const left = this._left - if (left && left.maxEnd >= startingAt && left.maxLength >= minLength) { - const found = left.findOneByLengthStartingAt(minLength, startingAt, filterFn) - if (found) - return found - } + let best = this._left?.findOneByLengthStartingAt(minLength, startingAt, filterFn) + // Once we reach future intervals, stored order and returned order agree. + if (best && this.start > startingAt) + return best - // Check self - for (let i = 0; i < this.values.length; i++) { - const interval = this.values[i] - if (interval.end < startingAt) - continue - if (interval.availableLength(startingAt) < minLength) + for (const interval of this.values) { + if (!(interval.availableLength(startingAt) >= minLength)) continue if (filterFn && !filterFn(interval)) continue - return interval.start < startingAt - ? new Interval(startingAt, interval.end, interval.data) - : interval + const candidate = clipStart(interval, startingAt) + if (!best || compareIntervals(candidate, best) < 0) + best = candidate + // Values share a start and are ordered by end; later values cannot win. + break } - // Check right subtree const right = this._right - if (right && right.maxEnd >= startingAt && right.maxLength >= minLength) { - return right.findOneByLengthStartingAt(minLength, startingAt, filterFn) + // A clipped candidate beats every future interval, but another interval + // containing startingAt may have a smaller end and must still be checked. + if (right && (!best || right.minStart <= startingAt)) { + const candidate = right.findOneByLengthStartingAt(minLength, startingAt, filterFn) + if (candidate && (!best || compareIntervals(candidate, best) < 0)) + best = candidate } - - return undefined + return best } public searchByLengthStartingAt( @@ -457,9 +454,7 @@ class Node { if (interval.end < startingAt) continue if (interval.availableLength(startingAt) >= minLength) { - result.push(interval.start < startingAt - ? new Interval(startingAt, interval.end, interval.data) - : interval) + result.push(clipStart(interval, startingAt)) } } diff --git a/src/compareIntervals.ts b/src/compareIntervals.ts index ade9319..8a33ac2 100644 --- a/src/compareIntervals.ts +++ b/src/compareIntervals.ts @@ -1,5 +1,2 @@ -import type { Interval } from './Interval' - -export function compareIntervals(a: Interval, b: Interval): number { - return a.start - b.start || a.end - b.end -} +// Preserve the existing import path and public export. +export { compareIntervals } from './order' diff --git a/src/gaps.ts b/src/gaps.ts index d7a3b5b..3d6fa73 100644 --- a/src/gaps.ts +++ b/src/gaps.ts @@ -1,4 +1,4 @@ -import type { IntervalTree } from './IntervalTree' +import type { IntervalCollection } from './IntervalCollection' import { assert } from './assert' import { Interval } from './Interval' import { subtractRanges } from './rangeSubtraction' @@ -17,9 +17,9 @@ import { subtractRanges } from './rangeSubtraction' * - Intervals overlapping the range are clipped to it. * - An empty tree yields a single gap `[start, end)`. * - A tree fully covering `[start, end)` yields `[]`. - * - `start >= end` throws, matching `chop`. + * - `start >= end` throws; unlike `chop`, this spike requires a positive window. */ -export function gaps(tree: IntervalTree, start: number, end: number): Interval[] { +export function gaps(tree: IntervalCollection, start: number, end: number): Interval[] { assert(start < end, 'start must be < end') return subtractRanges([new Interval(start, end)], tree.searchOverlap(start, end)) diff --git a/src/modelCheck.test.ts b/src/modelCheck.test.ts index 3ee8793..f07f783 100644 --- a/src/modelCheck.test.ts +++ b/src/modelCheck.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' import { ArrayIntervalCollection } from './ArrayIntervalCollection' import { Interval } from './Interval' import { IntervalTree } from './IntervalTree' +import { isCanonical } from './order' /** * Canonical string form: sorted by (start, end, data label) so tie order @@ -17,6 +18,7 @@ import { IntervalTree } from './IntervalTree' * the same bounds. */ function canon(ivs: Interval[]): string[] { + expect(isCanonical(ivs)).toBe(true) const label = (d: unknown) => d === undefined ? '' : typeof d === 'object' ? JSON.stringify(d) : String(d) return ivs .map(iv => [iv.start, iv.end, label(iv.data)] as const) @@ -30,9 +32,9 @@ function canon(ivs: Interval[]): string[] { * which one wins is unspecified — see MergeOverlapsCommand. */ function canonBounds(ivs: Interval[]): string[] { + expect(isCanonical(ivs)).toBe(true) return ivs .map(iv => [iv.start, iv.end] as const) - .sort((a, b) => a[0] - b[0] || a[1] - b[1]) .map(t => `${t[0]},${t[1]}`) } @@ -135,6 +137,11 @@ class FindOneByLengthStartingAtCommand implements fc.Command true run(m: ArrayIntervalCollection, r: IntervalTree): void { - // Verify findOneByLengthStartingAt against model: find qualifying intervals manually - const result = r.findOneByLengthStartingAt(this.minLength, this.startingAt) + const filter = (iv: Interval) => iv.start % 2 === 0 + const result = r.findOneByLengthStartingAt(this.minLength, this.startingAt, filter) const qualifying = m.toArray() - .filter((iv) => { - const adjustedLength = iv.end - Math.max(iv.start, this.startingAt) - return iv.end > this.startingAt && adjustedLength >= this.minLength - }) + .filter(iv => iv.availableLength(this.startingAt) >= this.minLength && filter(iv)) + .map(iv => new Interval(Math.max(iv.start, this.startingAt), iv.end, iv.data)) .sort((a, b) => a.start - b.start || a.end - b.end) - - if (qualifying.length === 0) { - expect(result).toBeUndefined() - } - else { - expect(result).toBeDefined() - // Result start should be adjusted to startingAt if interval begins earlier - const expectedStart = Math.max(qualifying[0].start, this.startingAt) - expect(result!.start).toEqual(expectedStart) - } + expect(result?.start).toBe(qualifying[0]?.start) + expect(result?.end).toBe(qualifying[0]?.end) } toString = () => `findOneWithFilter(${this.minLength}, ${this.startingAt})` @@ -665,6 +662,9 @@ class ToTuplesCommand implements fc.Command JSON.stringify(t))) const mTuples = new Set(m.toTuples().map(t => JSON.stringify(t))) expect(rTuples).toEqual(mTuples) diff --git a/src/order.spec.ts b/src/order.spec.ts new file mode 100644 index 0000000..4b6a6ff --- /dev/null +++ b/src/order.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { ArrayIntervalCollection } from './ArrayIntervalCollection' +import { Interval } from './Interval' +import { IntervalTree } from './IntervalTree' +import { clipStart, compareIntervals, isCanonical } from './order' + +const bounds = (ivs: Interval[]) => ivs.map(iv => [iv.start, iv.end]) + +it('orders by start and end, with no data tie-break', () => { + const a = new Interval(0, 10, 'a') + expect(compareIntervals(a, new Interval(0, 5))).toBeGreaterThan(0) + expect(compareIntervals(a, new Interval(0, 10, 'b'))).toBe(0) + expect(isCanonical([new Interval(0, 5), a])).toBe(true) + expect(isCanonical([a, new Interval(0, 5)])).toBe(false) + expect(clipStart(a, -1)).toBe(a) + expect(clipStart(a, 4).toTuple()).toEqual([4, 10, 'a']) + expect(isCanonical([new Interval(-Infinity, Infinity, 'a'), new Interval(-Infinity, Infinity, 'b')])).toBe(true) +}) + +describe.each([{ name: 'tree', Collection: IntervalTree }, { name: 'array', Collection: ArrayIntervalCollection }])('canonical queries: $name', ({ Collection }) => { + it('findOne selects the first returned bounds after clipping and filters stored intervals', () => { + const c = new Collection([new Interval(0, 10, 'wide'), new Interval(3, 5, 'tight')]) + expect(c.findOneByLengthStartingAt(1, 4)?.toTuple()).toEqual([4, 5, 'tight']) + expect(c.findOneByLengthStartingAt(1, 4, iv => iv.start === 0)?.toTuple()).toEqual([4, 10, 'wide']) + expect(c.findOneByLengthStartingAt(1, 4, () => false)).toBeUndefined() + expect(c.findOneByLengthStartingAt(1, 4)).toEqual(c.searchByLengthStartingAt(1, 4)[0]) + expect(() => c.findOneByLengthStartingAt(0, 4)).toThrow('minLength must be > 0') + expect(() => c.searchByLengthStartingAt(0, 4)).toThrow('minLength must be > 0') + }) + + it('returns ordered arrays after construction, incremental insertion, and cloning', () => { + const ivs = Array.from({ length: 64 }, (_, i) => new Interval(i, 1000)) + const bulk = new Collection(ivs) + const added = new Collection() + // An insertion order different from both canonical and balanced bulk order. + for (const iv of [...ivs.filter((_, i) => i % 2), ...ivs.filter((_, i) => !(i % 2))].reverse()) + added.add(iv) + for (const c of [bulk, added, added.clone()]) { + expect(bounds(c.toArray())).toEqual(bounds(ivs)) + expect(bounds(c.searchPoint(500))).toEqual(bounds(ivs)) + expect(bounds(c.searchOverlap(500, 501))).toEqual(bounds(ivs)) + expect(bounds(c.searchEnveloped(0, 1000))).toEqual(bounds(ivs)) + expect(isCanonical(c.searchByLengthStartingAt(1, 500))).toBe(true) + } + }) +}) diff --git a/src/order.ts b/src/order.ts new file mode 100644 index 0000000..a1654ef --- /dev/null +++ b/src/order.ts @@ -0,0 +1,18 @@ +import { Interval } from './Interval' + +/** Canonical order of returned bounds; identical bounds have no data tie-break. */ +export function compareIntervals(a: Interval, b: Interval): number { + return (a.start === b.start ? 0 : a.start - b.start) + || (a.end === b.end ? 0 : a.end - b.end) +} + +/** The caller must first check that positive length remains after startingAt. */ +export function clipStart(interval: Interval, startingAt: number): Interval { + return interval.start < startingAt + ? new Interval(startingAt, interval.end, interval.data) + : interval +} + +export function isCanonical(intervals: readonly Interval[]): boolean { + return intervals.every((iv, i) => i === 0 || compareIntervals(intervals[i - 1], iv) <= 0) +} From 7fd224cefe0c04117d4ac317fe1d6d9112baebd4 Mon Sep 17 00:00:00 2001 From: Jordan Baker Date: Sun, 4 Oct 2026 09:15:04 -0600 Subject: [PATCH 3/5] ci: verify packed consumer compatibility and release labels --- .github/workflows/ci.yml | 44 +++++++++++++++++++++++ .github/workflows/release-health.yml | 29 ++++++++++++++++ docs/design/release-checks.md | 33 ++++++++++++++++++ eslint.config.js | 3 ++ package.json | 3 +- scripts/check-dist-debug.mjs | 1 + scripts/check-focuster-compat.sh | 15 ++++++++ scripts/release-health.mjs | 52 ++++++++++++++++++++++++++++ test/dist-debug.test.mjs | 30 ++++++++++++++++ test/release-health.test.mjs | 43 +++++++++++++++++++++++ 10 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-health.yml create mode 100644 docs/design/release-checks.md create mode 100644 scripts/check-focuster-compat.sh create mode 100644 scripts/release-health.mjs create mode 100644 test/dist-debug.test.mjs create mode 100644 test/release-health.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5ecb9c..c234a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [master, main] pull_request: branches: [master, main] + types: [opened, synchronize, reopened, labeled, unlabeled] schedule: # Run full model check once a day at 2 AM UTC - cron: '0 2 * * *' @@ -23,6 +24,7 @@ jobs: - run: pnpm install - run: pnpm build + - run: pnpm test:automation - run: pnpm test - run: pnpm lint - run: pnpm typecheck @@ -75,3 +77,45 @@ jobs: - name: Test dist on Node ${{ matrix.node-version }} run: pnpm test:compat + + focuster-freelist: + # Only maintainer-owned release branches may access the private consumer. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release-please--branches--master')) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + path: intervaltree + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + package_json_file: intervaltree/package.json + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Pack the release candidate + working-directory: intervaltree + run: | + pnpm install --frozen-lockfile + mkdir -p "$RUNNER_TEMP/intervaltree-candidate" + pnpm pack --pack-destination "$RUNNER_TEMP/intervaltree-candidate" + - uses: actions/checkout@v4 + with: + repository: hexsprite/focuster + ref: master + path: focuster + # Prefer a repo-scoped read token; the existing release token is a fallback. + token: ${{ secrets.FOCUSTER_READ_TOKEN || secrets.RELEASE_PLEASE_TOKEN }} + persist-credentials: false + - name: Install the consumer test dependencies + working-directory: focuster + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Test the packed candidate against Focuster's freelist specs + run: bash intervaltree/scripts/check-focuster-compat.sh "$GITHUB_WORKSPACE/focuster" "$RUNNER_TEMP"/intervaltree-candidate/intervaltree-*.tgz diff --git a/.github/workflows/release-health.yml b/.github/workflows/release-health.yml new file mode 100644 index 0000000..2b296f8 --- /dev/null +++ b/.github/workflows/release-health.yml @@ -0,0 +1,29 @@ +name: Release health + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled, closed] + schedule: + - cron: '15 * * * *' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + release-labels: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Check for tagged releases still labeled pending + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: node scripts/release-health.mjs diff --git a/docs/design/release-checks.md b/docs/design/release-checks.md new file mode 100644 index 0000000..e2c496f --- /dev/null +++ b/docs/design/release-checks.md @@ -0,0 +1,33 @@ +# Release checks + +The release CI job packs this checkout and installs the tarball into a fresh +Focuster checkout. It runs the actual `imports/schedule/domain/freelist` Vitest +specs, using Focuster's own configuration and Meteor stubs. The check covers +the generic collection API used by the scheduler; it does not exercise the +Meteor server or browser. + +Focuster is private. Configure `FOCUSTER_READ_TOKEN` with read-only contents +access to `hexsprite/focuster`. Until that token is supplied, checkout uses the +existing `RELEASE_PLEASE_TOKEN`, which must also be allowed to read Focuster. +An authentication failure fails the job. The private checkout runs only for +release-please branches in this repository, and checkout does not persist +credentials. Dependency and test steps receive neither token in their env. + +For a local check, install Focuster's normal dependencies, pack intervaltree, +then run: + +```bash +pnpm pack --pack-destination /tmp/intervaltree-candidate +bash scripts/check-focuster-compat.sh /absolute/path/to/focuster /tmp/intervaltree-candidate/intervaltree-2.1.0.tgz +``` + +The script overrides the installed package without changing the consumer's +manifest or lockfile. Use a disposable checkout for candidate checks. + +The separate release-health workflow checks all PR pages, including merged +PRs. A pending release whose `v` tag exists fails with its PR URL. +Closed PRs that were never merged are abandoned and do not block releases. +Unknown release titles and GitHub permission/network errors also fail; only +a tag lookup returning HTTP 404 counts as a tag that does not exist. It runs +on PR label/close events, branch/tag pushes, hourly, and on manual dispatch. +It reports stale state without editing labels or triggering publication. diff --git a/eslint.config.js b/eslint.config.js index 3f1b3b1..8cd7650 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,6 +3,9 @@ import antfu from '@antfu/eslint-config' export default antfu({ typescript: true, ignores: ['dist/**', 'node_modules/**', '*.md', '*.json', '.github/**', '.beads/**', '.claude/**', '.agents/**', '.gc/**'], +}, { + files: ['test/*.test.mjs'], + rules: { 'test/no-import-node-test': 'off' }, }, { // antfu's default enforces trustPolicy: no-downgrade, which rejects the // current lockfile. Only require the setting this file exists for. diff --git a/package.json b/package.json index 920b3a1..c876d84 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "model-check": "vitest run --config vitest.model.config.ts", "test:all": "pnpm test && pnpm model-check", "bench": "NODE_ENV=production vitest bench --run --config vitest.bench.config.ts", - "test:compat": "rm -rf /tmp/itc && mkdir -p /tmp/itc && pnpm build && pnpm pack --pack-destination /tmp/itc >/dev/null && mkdir -p /tmp/itc/app && cp test/compat.cjs test/compat.mjs /tmp/itc/app/ && cd /tmp/itc/app && npm init -y >/dev/null && npm i --no-audit --no-fund /tmp/itc/intervaltree-*.tgz >/dev/null && node compat.cjs && node compat.mjs" + "test:compat": "rm -rf /tmp/itc && mkdir -p /tmp/itc && pnpm build && pnpm pack --pack-destination /tmp/itc >/dev/null && mkdir -p /tmp/itc/app && cp test/compat.cjs test/compat.mjs /tmp/itc/app/ && cd /tmp/itc/app && npm init -y >/dev/null && npm i --no-audit --no-fund /tmp/itc/intervaltree-*.tgz >/dev/null && node compat.cjs && node compat.mjs", + "test:automation": "node --test test/*.test.mjs" }, "devDependencies": { "@antfu/eslint-config": "^6.7.3", diff --git a/scripts/check-dist-debug.mjs b/scripts/check-dist-debug.mjs index cf00999..2f5953b 100644 --- a/scripts/check-dist-debug.mjs +++ b/scripts/check-dist-debug.mjs @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process' import { resolve } from 'node:path' +import process from 'node:process' import { pathToFileURL } from 'node:url' // Probe the public behavior in fresh processes with no inherited debug flags. diff --git a/scripts/check-focuster-compat.sh b/scripts/check-focuster-compat.sh new file mode 100644 index 0000000..7a04865 --- /dev/null +++ b/scripts/check-focuster-compat.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# != 2 ]]; then + echo 'Usage: check-focuster-compat.sh ' >&2 + exit 2 +fi + +consumer="$1" +candidate="$2" +[[ -f "$candidate" ]] || { echo "Candidate not found: $candidate" >&2; exit 2; } +cd "$consumer" +npm install --no-save --package-lock=false --ignore-scripts --no-audit --no-fund "$candidate" +node -p "'Testing packed intervaltree ' + require('intervaltree/package.json').version" +npm run test-unit -- imports/schedule/domain/freelist diff --git a/scripts/release-health.mjs b/scripts/release-health.mjs new file mode 100644 index 0000000..3ea5ef3 --- /dev/null +++ b/scripts/release-health.mjs @@ -0,0 +1,52 @@ +import { resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +/** Includes merged PRs: release-please can stall on a label left on a merged release. */ +export async function findStalledReleases(repository, token, request = fetch) { + if (!repository || !token) + throw new Error('GITHUB_REPOSITORY and GH_TOKEN are required') + const headers = { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + const base = `https://api.github.com/repos/${repository}` + const stalled = [] + for (let page = 1; ; page++) { + const response = await request(`${base}/pulls?state=all&per_page=100&page=${page}`, { headers }) + if (!response.ok) + throw new Error(`Cannot inspect release PRs: HTTP ${response.status}`) + const pulls = await response.json() + for (const pr of pulls) { + // An abandoned, unmerged PR cannot hold up a release. + if (pr.state === 'closed' && !pr.merged_at) + continue + if (!pr.labels.some(label => label.name === 'autorelease: pending')) + continue + const version = pr.title.match(/\brelease\s+(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)(?:\s|$)/)?.[1] + if (!version) + throw new Error(`Cannot determine the version of pending release PR #${pr.number}`) + const tag = `v${version}` + const tagged = await request(`${base}/git/ref/tags/${encodeURIComponent(tag)}`, { headers }) + if (tagged.ok) + stalled.push({ number: pr.number, tag, url: pr.html_url }) + else if (tagged.status !== 404) + throw new Error(`Cannot inspect ${tag}: HTTP ${tagged.status}`) + } + if (pulls.length < 100) + return stalled + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const stalled = await findStalledReleases(process.env.GITHUB_REPOSITORY, process.env.GH_TOKEN) + if (stalled.length) { + for (const pr of stalled) + process.stderr.write(`Release PR #${pr.number} still has autorelease: pending after ${pr.tag} exists: ${pr.url}\n`) + process.exitCode = 1 + } + else { + process.stdout.write('Release labels are healthy.\n') + } +} diff --git a/test/dist-debug.test.mjs b/test/dist-debug.test.mjs new file mode 100644 index 0000000..81e8c90 --- /dev/null +++ b/test/dist-debug.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { test as it } from 'node:test' + +it('the dist guard survives renaming and rejects frozen-on builds in both formats', () => { + const dir = mkdtempSync(join(tmpdir(), 'intervaltree-debug-')) + try { + for (const extension of ['js', 'cjs']) { + const renamed = readFileSync(`dist/index.${extension}`, 'utf8') + .replaceAll('INTERVALTREE_DEBUG', 'RENAMED_FLAG') + .replaceAll('DEBUG', 'RENAMED_CONSTANT') + const file = join(dir, extension === 'js' ? 'index.mjs' : 'index.cjs') + const probe = () => spawnSync(process.execPath, ['scripts/check-dist-debug.mjs', file], { encoding: 'utf8' }) + writeFileSync(file, renamed) + assert.equal(probe().status, 0, `renamed ${extension} build must pass`) + assert.match(renamed, /var RENAMED_CONSTANT = .*;/) + writeFileSync(file, renamed.replace(/var RENAMED_CONSTANT = .*;/, 'var RENAMED_CONSTANT = true;')) + const frozen = probe() + assert.notEqual(frozen.status, 0) + assert.match(frozen.stderr, /automatic invariant checks must be off by default/) + } + } + finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/test/release-health.test.mjs b/test/release-health.test.mjs new file mode 100644 index 0000000..3872f35 --- /dev/null +++ b/test/release-health.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { test as it } from 'node:test' +import { findStalledReleases } from '../scripts/release-health.mjs' + +const pending = { number: 12, title: 'chore(master): release 2.1.0', html_url: 'https://github.com/hexsprite/intervaltree/pull/12', state: 'closed', merged_at: '2026-10-04', labels: [{ name: 'autorelease: pending' }] } +const response = (status, data) => ({ ok: status === 200, status, json: async () => data }) + +it('a merged pending PR fails when its tag already exists', async () => { + const stalled = await findStalledReleases('hexsprite/intervaltree', 'test', async url => + url.includes('/pulls?') ? response(200, [pending]) : response(200, {})) + assert.deepEqual(stalled, [{ number: 12, tag: 'v2.1.0', url: pending.html_url }]) +}) + +it('an untagged pending release and an already tagged label are healthy', async () => { + const tagged = { ...pending, labels: [{ name: 'autorelease: tagged' }] } + assert.deepEqual(await findStalledReleases('r/p', 'test', async url => + url.includes('/pulls?') ? response(200, [pending, tagged]) : response(404)), []) +}) + +it('checks later pages and propagates permission failures', async () => { + const ordinary = { ...pending, labels: [] } + const request = async (url) => { + if (new URL(url).searchParams.get('page') === '1') + return response(200, Array.from({ length: 100 }, () => ordinary)) + if (new URL(url).searchParams.get('page') === '2') + return response(200, [pending]) + return response(200, {}) + } + assert.equal((await findStalledReleases('r/p', 'test', request)).length, 1) + await assert.rejects(findStalledReleases('r/p', 'test', async url => + url.includes('/pulls?') ? response(200, [pending]) : response(403)), /HTTP 403/) + await assert.rejects(findStalledReleases('r/p', 'test', async () => response(500)), /HTTP 500/) +}) + +it('a pending label with an unrecognized release title cannot pass silently', async () => { + await assert.rejects(findStalledReleases('r/p', 'test', async () => + response(200, [{ ...pending, title: 'not a release' }])), /Cannot determine the version/) +}) + +it('ignores abandoned release PRs that were closed without merging', async () => { + const abandoned = { ...pending, merged_at: null } + assert.deepEqual(await findStalledReleases('r/p', 'test', async () => response(200, [abandoned])), []) +}) From d86e7fd82021a07acc57eb8f462fcb4d31ea3b5f Mon Sep 17 00:00:00 2001 From: Jordan Baker Date: Sun, 4 Oct 2026 09:17:01 -0600 Subject: [PATCH 4/5] bd: update sync.remote --- .beads/config.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.beads/config.yaml b/.beads/config.yaml index b133372..59ab8a2 100644 --- a/.beads/config.yaml +++ b/.beads/config.yaml @@ -61,5 +61,7 @@ import.auto: false # - github.repo types.custom: molecule,convoy,message,event,gate,merge-request,agent,role,rig,session,spec,convergence,step dolt: - disable-event-flush: true + disable-event-flush: true backup.enabled: false +sync: + remote: "git+https://github.com/hexsprite/intervaltree.git" From ee7e03c8e552ef6c3176da97b81256e0314245b5 Mon Sep 17 00:00:00 2001 From: Jordan Baker Date: Sun, 4 Oct 2026 09:19:51 -0600 Subject: [PATCH 5/5] ci: preserve the consumer dependency tree during candidate checks --- docs/design/release-checks.md | 6 ++++-- scripts/check-focuster-compat.sh | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/design/release-checks.md b/docs/design/release-checks.md index e2c496f..a195ffa 100644 --- a/docs/design/release-checks.md +++ b/docs/design/release-checks.md @@ -21,8 +21,10 @@ pnpm pack --pack-destination /tmp/intervaltree-candidate bash scripts/check-focuster-compat.sh /absolute/path/to/focuster /tmp/intervaltree-candidate/intervaltree-2.1.0.tgz ``` -The script overrides the installed package without changing the consumer's -manifest or lockfile. Use a disposable checkout for candidate checks. +The script installs the self-contained distribution in an isolated directory, +temporarily replaces only the installed intervaltree package, and restores it +on exit. It preserves the consumer's manifest, lockfile, and other installed +dependencies. A candidate with runtime dependencies fails explicitly. The separate release-health workflow checks all PR pages, including merged PRs. A pending release whose `v` tag exists fails with its PR URL. diff --git a/scripts/check-focuster-compat.sh b/scripts/check-focuster-compat.sh index 7a04865..82bcf3c 100644 --- a/scripts/check-focuster-compat.sh +++ b/scripts/check-focuster-compat.sh @@ -10,6 +10,20 @@ consumer="$1" candidate="$2" [[ -f "$candidate" ]] || { echo "Candidate not found: $candidate" >&2; exit 2; } cd "$consumer" -npm install --no-save --package-lock=false --ignore-scripts --no-audit --no-fund "$candidate" +harness="$(mktemp -d "$PWD/node_modules/.intervaltree-compat.XXXXXX")" +cleanup() { + if [[ -e "$harness/original" || -L "$harness/original" ]]; then + rm -rf node_modules/intervaltree + mv -f "$harness/original" node_modules/intervaltree + fi + rm -rf "$harness" +} +trap cleanup EXIT + +# Installing in isolation leaves the consumer's locked dependency tree intact. +npm install --prefix "$harness" --no-save --ignore-scripts --no-audit --no-fund "$candidate" +node -e 'const p=require(process.argv[1]); if(Object.keys(p.dependencies || {}).length) throw new Error("The isolated override requires a self-contained intervaltree distribution")' "$harness/node_modules/intervaltree/package.json" +mv -f node_modules/intervaltree "$harness/original" +cp -rf "$harness/node_modules/intervaltree" node_modules/intervaltree node -p "'Testing packed intervaltree ' + require('intervaltree/package.json').version" npm run test-unit -- imports/schedule/domain/freelist