From e44f8ad824072471334b8e1b80274e51cd40d3bc Mon Sep 17 00:00:00 2001 From: os-sam Date: Mon, 31 Aug 2026 14:24:56 +0000 Subject: [PATCH] fix(plugin-timeline): judge a gantt Date by its [[DateValue]] slot, not its prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isGanttDateType` used `value instanceof Date`, which answers "does this inherit from `Date.prototype`" rather than "is this a Date". A slot-less impostor passed the gate, reached `new Date(value)`, ran ToPrimitive and threw uncaught mid-render — a blank screen where objectui#6781's named diagnostic belongs. Both Date tests in this file now ask a total brand test built on the builtin `Date.prototype.getTime` invoked with `.call`: it reads the receiver's `[[DateValue]]` slot and nothing else, so no author getter runs, no `Symbol.toStringTag` is consulted and no proxy trap fires. The speller's branch selector had to move with the gate: with the gate fixed and `spellGanttDateValue` still selecting on `instanceof Date`, the impostor is refused and then crashes inside `Date.prototype.toString.call` while being named — the crash relocates instead of closing. Measured both ways; no spelling changes for any value that reaches the speller today. Adds a pinned adversarial input set so the totality is exercised rather than asserted for a fifth time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/7027-gantt-date-brand-gate.md | 32 ++ .../timeline-gantt-date-brand-7027.test.tsx | 349 ++++++++++++++++++ packages/plugin-timeline/src/renderer.tsx | 128 ++++++- 3 files changed, 496 insertions(+), 13 deletions(-) create mode 100644 .changeset/7027-gantt-date-brand-gate.md create mode 100644 packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx diff --git a/.changeset/7027-gantt-date-brand-gate.md b/.changeset/7027-gantt-date-brand-gate.md new file mode 100644 index 0000000000..49f4be4ac1 --- /dev/null +++ b/.changeset/7027-gantt-date-brand-gate.md @@ -0,0 +1,32 @@ +--- +'@object-ui/plugin-timeline': patch +--- + +Gantt date type gate: judge the `[[DateValue]]` slot, not the prototype chain +(objectui#7027). + +`isGanttDateType` asked `value instanceof Date`, which answers "does this +inherit from `Date.prototype`?" and not "is this a `Date`?". An object that +inherits the prototype without owning the internal slot passed the gate, +reached `new Date(value)`, ran ToPrimitive, and threw +`TypeError: Method Date.prototype.toString called on incompatible receiver` — +uncaught, mid-render, so the author got a blank screen where #6781's named +diagnostic belongs. Three spellings crashed on `main`, measured: +`Object.create(Date.prototype)`, that impostor behind a `Proxy` that throws on +every get, and a `Proxy` with a throwing `getPrototypeOf` trap (`instanceof` is +not total on its own terms either). + +Both sites now ask a total brand test that invokes the builtin +`Date.prototype.getTime` with `.call`: it reads the receiver's `[[DateValue]]` +slot and nothing else, so no author getter runs, no `Symbol.toStringTag` is +consulted, and no proxy trap fires. The two brand tests the finding suggested +were measured and rejected — `Object.prototype.toString.call` performs +`Get(O, @@toStringTag)` unconditionally, and `Number.isFinite(value.getTime())` +calls the author's `getTime`, which would refuse a real `Date` subclass by +dying on it. Both are pinned as red rows. + +No change to which values are accepted: #6781's accept set +(`string | finite number | Date`) is untouched, `new Date(NaN)` still passes +the type gate and is still refused by the parse check with its `Invalid Date` +spelling, and every newly-refused value is one no authored document can carry +(ObjectUI metadata is JSON, which cannot spell a prototype). diff --git a/packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx new file mode 100644 index 0000000000..3cb6e00dc3 --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx @@ -0,0 +1,349 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7027 — the gantt date TYPE GATE is made total, and the totality is + * EXERCISED rather than asserted. + * + * ## Why this file exists at all, and why it is the deliverable + * + * Four cards have now made a totality claim about this one code path, and each + * of the first three was falsified by the next one's MEASUREMENT: + * + * 1. #6759 declared `spellGanttDateValue` "total by construction". + * 2. #6905 (#6781's type rule) made that property load-bearing by ordering + * the type gate BEFORE `new Date`. + * 3. #6907 (PR #7026) measured that the speller was NOT total — `String` + * ran author `toString`, and three inputs crashed the render on `main`. + * The gate had not removed the crash class; it moved it downstream. + * 4. This card: the GATE has a gap of the same kind, one function upstream. + * `value instanceof Date` answers "does this inherit from + * `Date.prototype`", not "is this a Date", and the two differ. + * + * A fifth prose assertion would continue the sequence. What ends it is a + * PINNED ADVERSARIAL INPUT SET — every way the language lets an object lie + * about being a Date, each one asserted to reach a DEFINED outcome. That is + * what #7026 gave the speller and what the gate did not have. + * + * ## What "a defined outcome" means here — two kinds, and never a third + * + * ⚠️ Not every row below is a refusal, and forcing them all to be one would + * change the accept set, which is #6781's ruling and is untouched by this card. + * Every input reaches exactly one of: + * + * - ACCEPTED — it is a real Date, so the chart draws (rows 5 and 6 below). + * - NAMED — it is refused through #6759/#6770/#6781's single existing + * diagnostic, with the authored path and a spelling. + * + * and never the third, which is what this card removes: + * + * - THREW — an uncaught TypeError mid-render, i.e. a blank screen where + * a named diagnostic belongs. + * + * ## The base readings — measured on 7fc5c3c12, node probe + this suite + * + * Object.create(Date.prototype) -> THREW TypeError: Method + * Date.prototype.toString called on incompatible receiver + * [object Object] (in `isUnusable`, and again + * in `spellGanttDateValue`) + * new Proxy(Object.create(Date.prototype), + * { get() { throw } }) -> THREW (the get trap, via + * ToPrimitive in `new Date`) + * new Proxy({}, { getPrototypeOf() { throw } }) + * -> THREW (`instanceof` + * itself is not total) + * class X extends Date { getTime() { throw } } + * -> accepted, chart drew (the + * CONTROL that rejects one of + * the two suggested repairs) + * { get [Symbol.toStringTag]() { throw } } -> named "an object" (the + * CONTROL that rejects the + * other suggested repair) + * new Proxy({}, { get() { throw } }) -> named "an object" + * new Date('2024-01-01') -> accepted, chart drew + * new Date(NaN) -> named "Invalid Date" + * + * The three THREW rows are the defect. The two CONTROL rows are the reason the + * repair is neither of the two the card suggested — see pin 2. + * + * ## Assertions count BAR ELEMENTS, never styles + * + * #6759's rule, inherited through #6770 and #6781: a bar whose geometry is + * `NaN` carries NO `style` attribute at all, so an assertion phrased over + * styles reads identically for "the bar is gone" and "the bar is there and + * broken". Refusal assertions are positive about the diagnostic and count + * elements; styles are read only where an UNCHANGED geometry is the point. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { TimelineRenderer } from '../renderer'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal() as Promise>); + return { + ...actual, + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'overlay', + view: undefined, + }), + useObjectLabel: () => ({ + fieldOptionLabel: (_o: string, _f: string, _v: string, fb: string) => fb, + translateOptions: (_o: string, _f: string, opts: unknown[]) => opts, + fieldLabel: (_o: string, _f: string, fb: string) => fb, + }), + }; +}); + +const TESTID = 'timeline-unusable-date-range'; +const PATH = 'items[0].items[0].endDate'; + +/** The axis header cells the gantt branch emits, in order. */ +const axisOf = (container: HTMLElement): string[] => + Array.from(container.querySelectorAll('.border-r.text-xs.font-medium.text-center')).map( + (n) => n.textContent ?? '', + ); + +/** How many bar ELEMENTS exist — not their geometry. See the header. */ +const barCountOf = (container: HTMLElement): number => + container.querySelectorAll('.absolute.h-8.rounded-md').length; + +/** The diagnostic's text, or `null` when the gantt rendered instead. */ +const diagnosticOf = (container: HTMLElement): string | null => { + const el = container.querySelector(`[data-testid="${TESTID}"]`); + return el ? el.textContent ?? '' : null; +}; + +const gantt = (schema: Record) => + render(); + +/** One row carrying one item, so a case only has to say what is wrong with it. */ +const rowWith = (item: Record) => [{ label: 'R', items: [{ title: 'T', ...item }] }]; + +/** + * The adversarial constructors, as FACTORIES. + * + * Each is built fresh per case on purpose: several of these are single-use + * (a revoked proxy, a getter that throws) and a shared instance would let one + * case's first touch decide another case's reading. + */ + +/** Inherits `Date.prototype`, owns no `[[DateValue]]` slot. The filed defect. */ +const slotlessImpostor = () => Object.create(Date.prototype) as unknown; + +/** The same impostor, behind a proxy that also refuses every property read. */ +const hostileSlotlessImpostor = () => + new Proxy(Object.create(Date.prototype), { + get() { + throw new Error('get trap: no property of this value may be read'); + }, + }) as unknown; + +/** Breaks `instanceof` ITSELF — `instanceof` walks `[[GetPrototypeOf]]`. */ +const prototypeHostileProxy = () => + new Proxy( + {}, + { + getPrototypeOf() { + throw new Error('getPrototypeOf trap: this value has no readable prototype'); + }, + }, + ) as unknown; + +/** Refuses every property read, but is honest about its prototype. */ +const readHostileProxy = () => + new Proxy( + {}, + { + get() { + throw new Error('get trap: no property of this value may be read'); + }, + }, + ) as unknown; + +/** A REAL Date (it owns its slot) whose `getTime` is hijacked to throw. */ +class HijackedGetTime extends Date { + override getTime(): number { + throw new Error('getTime hijacked by the authored document'); + } +} + +/** An object whose `Symbol.toStringTag` is a throwing getter. */ +const throwingToStringTag = () => + ({ + get [Symbol.toStringTag](): string { + throw new Error('Symbol.toStringTag getter throws'); + }, + }) as unknown; + +describe('pin 1 — a Date IMPOSTOR is NAMED, never thrown (objectui#7027)', () => { + /** + * The defect, in its three measured spellings. Every one of these passed + * `value instanceof Date` (or crashed inside it) on 7fc5c3c12 and took the + * render down with an uncaught `TypeError`. + * + * The assertion is deliberately about the DIAGNOSTIC and not about the + * absence of a throw: `render` propagates a render-time error, so a + * regression here fails loudly on the first line rather than on a + * `not.toThrow` that would also pass for a silently-drawn wrong chart. + */ + const impostors: [string, () => unknown][] = [ + [ + 'Object.create(Date.prototype) — inherits the prototype, owns no [[DateValue]] slot', + slotlessImpostor, + ], + [ + 'the same impostor behind a Proxy that throws on every get', + hostileSlotlessImpostor, + ], + [ + 'a Proxy whose getPrototypeOf trap throws — `instanceof` is not total either', + prototypeHostileProxy, + ], + ]; + + for (const [label, make] of impostors) { + it(`names ${label}`, () => { + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: make() }) }); + + const el = screen.getByTestId(TESTID); + // The same channel #6759/#6770/#6781 use. No second diagnostic, no new + // i18n key — an impostor is refused as the non-date it is. + expect(el.getAttribute('role')).toBe('alert'); + const text = el.textContent ?? ''; + expect(text, 'the diagnostic did not name the authored path').toContain(PATH); + // Named by TYPE, never rendered: producing text from an impostor is the + // one operation that hands control back to the authored document. + expect(text, 'the impostor was rendered instead of named').toContain('is an object'); + // No chart was drawn from a value that is not a date. + expect(axisOf(container), 'a chart was drawn from a Date impostor').toEqual([]); + expect(barCountOf(container)).toBe(0); + expect(screen.queryByText('T')).toBeNull(); + }); + } + + it('a Proxy that throws on every get is named, not thrown', () => { + // Refused on the base too (its prototype is `Object.prototype`), so this + // row is a REGRESSION pin rather than a repair: the gate's new brand test + // must not start reading properties off the value. + const { container } = gantt({ + items: rowWith({ startDate: '2024-01-01', endDate: readHostileProxy() }), + }); + + const text = diagnosticOf(container) ?? ''; + expect(text).toContain(PATH); + expect(text).toContain('is an object'); + expect(barCountOf(container)).toBe(0); + }); +}); + +describe('pin 2 — the two SUGGESTED repairs, each with the input that refutes it (objectui#7027)', () => { + /** + * The card offered two brand tests and triage flagged both as "not free". + * These two rows are why neither was taken, and they are the rows that go + * red if a later reader "simplifies" the gate into one of them. + */ + + it('a Date subclass whose `getTime` throws is ACCEPTED — refutes `Number.isFinite(value.getTime())`', () => { + // It is a REAL Date: `super()` gave it a `[[DateValue]]` slot, so it is + // inside #6781's accept set and the chart must draw. A gate written as + // `Number.isFinite(value.getTime())` would call the AUTHOR's `getTime` and + // die here — trading the impostor crash for a subclass crash. + // + // `new Date(x)` never runs ToPrimitive on a value that owns the slot, so + // the hijack is unreachable from the guard as written. + const { container } = gantt({ + items: rowWith({ startDate: '2024-01-01', endDate: new HijackedGetTime('2024-03-01') }), + }); + + expect(diagnosticOf(container), 'a real Date subclass was refused').toBeNull(); + expect(barCountOf(container), 'the chart did not draw for a real Date').toBe(1); + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); + }); + + it('an object with a throwing `Symbol.toStringTag` getter is NAMED — refutes `Object.prototype.toString.call`', () => { + // `Object.prototype.toString` always performs `Get(O, @@toStringTag)` + // (ES2015 step 16) even when the builtin tag is already decided, so the + // brand test runs this getter and throws. Measured — the same fact #6907 + // recorded when it refused that spelling inside `spellGanttDateValue`. + const { container } = gantt({ + items: rowWith({ startDate: '2024-01-01', endDate: throwingToStringTag() }), + }); + + const text = diagnosticOf(container) ?? ''; + expect(text).toContain(PATH); + expect(text).toContain('is an object'); + expect(barCountOf(container)).toBe(0); + }); +}); + +describe('pin 3 — the ACCEPT SET is unchanged: #6781’s ruling does not move (objectui#7027)', () => { + /** + * This card repairs a CRASH; it adjudicates nothing. Every value an author + * can write must land exactly where #6781 put it. These are the controls. + */ + + it('a real, valid `Date` still draws its chart', () => { + const { container } = gantt({ + items: rowWith({ startDate: '2024-01-01', endDate: new Date('2024-03-01') }), + }); + + expect(diagnosticOf(container), 'a real Date was refused').toBeNull(); + expect(barCountOf(container)).toBe(1); + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); + }); + + it('`new Date(NaN)` is still refused by the PARSE check, and still spelled `Invalid Date`', () => { + // The load-bearing half of "do not change the accept set": an invalid Date + // owns its slot, so it must pass the TYPE gate and be refused one step + // later. A brand test that refused it would move the diagnostic's spelling + // from `Invalid Date` to `an object` — a visible change to an author. + const { container } = gantt({ + items: rowWith({ startDate: '2024-01-01', endDate: new Date(Number.NaN) }), + }); + + const text = diagnosticOf(container) ?? ''; + expect(text).toContain(PATH); + expect(text, 'the invalid Date lost its `Invalid Date` spelling').toContain('Invalid Date'); + expect(barCountOf(container)).toBe(0); + }); + + it('the three accepted spellings and the three refused ones are all where #6781 left them', () => { + // A compact re-assertion across the accept-set boundary, so this file fails + // if the brand test is ever widened or narrowed past the crash class. + const accepted: [string, unknown][] = [ + ['a date string', '2024-03-01'], + ['a finite number (epoch ms)', 1709251200000], + ['a real Date', new Date('2024-03-01')], + ]; + for (const [label, value] of accepted) { + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: value }) }); + expect(diagnosticOf(container), `${label} was refused`).toBeNull(); + expect(barCountOf(container), `${label} drew no bar`).toBe(1); + } + + const refused: [string, unknown][] = [ + ['null', null], + ['undefined', undefined], + ['a boolean', false], + ]; + for (const [label, value] of refused) { + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: value }) }); + expect(diagnosticOf(container) ?? '', `${label} was accepted`).toContain(PATH); + expect(barCountOf(container), `${label} drew a bar`).toBe(0); + } + }); +}); diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index 0b3fc3599e..648f778427 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -271,6 +271,77 @@ function calculateDateRange(items: any[]): { minDate: string; maxDate: string } }; } +/** + * "Is this value a `Date`?" — asked so that NOTHING the authored document + * controls can answer it, and so that asking cannot throw (objectui#7027). + * + * ## What was wrong with `instanceof Date` + * + * It is this repo's idiom for the question (see + * `packages/core/src/validation/validation-engine.ts` and + * `components/src/renderers/complex/data-table.tsx`), and it answers a + * DIFFERENT question: "does this inherit from `Date.prototype`?". A `Date` is + * a Date because it owns a `[[DateValue]]` internal slot; the prototype chain + * is decoration, and the two come apart in both directions. Measured on this + * card's base 7fc5c3c12 with a node probe: + * + * Object.create(Date.prototype) instanceof Date -> true + * new Date(Object.create(Date.prototype)) -> THREW TypeError: + * Method Date.prototype.toString called on incompatible receiver + * [object Object] + * + * `new Date(x)` uses the slot directly when `x` owns one and runs ToPrimitive + * when it does not — so an impostor that passed the gate reached `new Date`, + * reached `Date.prototype[Symbol.toPrimitive]`, reached + * `Date.prototype.toString` on a receiver with no slot, and took the render + * down with it. That is a blank screen where a named diagnostic belongs, which + * is the exact failure mode #6781 put the type gate here to remove and #6907 + * removed one function downstream. + * + * `instanceof` is not even total on its own terms: it walks + * `[[GetPrototypeOf]]`, so a `Proxy` with a throwing `getPrototypeOf` trap + * crashes inside the operator (measured, same probe). + * + * ## Why NOT the two brand tests the card suggested — both measured, both out + * + * - `Object.prototype.toString.call(value) === '[object Date]'` performs + * `Get(O, @@toStringTag)` UNCONDITIONALLY (ES2015 19.1.3.6 step 16), even + * once the builtin tag is decided. A `@@toStringTag` getter that throws is + * an authored value, and #6907 measured that exact input crashing the + * speller. It trades an impostor crash for a getter crash. + * - `Number.isFinite(value.getTime())` calls the AUTHOR'S `getTime`. A + * `class X extends Date` that overrides it is a REAL Date — `super()` gave + * it the slot, so #6781's accept set contains it and the chart must draw — + * and this spelling would refuse it by dying. It trades an impostor crash + * for a subclass crash, on a value that is not even wrong. + * + * Both are pinned as red rows in `timeline-gantt-date-brand-7027.test.tsx`, + * so a later "simplification" into either one fails there rather than in a + * fifth card. + * + * ## What this does instead + * + * Invokes the BUILTIN `Date.prototype.getTime` with `.call`. It reads the + * receiver's `[[DateValue]]` slot and nothing else: no property is fetched off + * `value`, so no author getter runs, no `@@toStringTag` is consulted, no proxy + * trap fires, and a subclass cannot hijack it. The slot is not observable any + * other way — the language exposes that bit only by throwing when it is + * absent — so the `catch` is the READ, not error handling draped over a + * fallible operation. + * + * `new Date(NaN)` owns its slot, so it is a `Date` here and is refused one + * step later by the parse check, keeping its `Invalid Date` spelling. That is + * the half of #6781's ruling this must not move. + */ +const isDate = (value: unknown): value is Date => { + try { + Date.prototype.getTime.call(value as Date); + return true; + } catch { + return false; + } +}; + /** * How a gantt date value is SPELLED inside a diagnostic (objectui#6759, * ruled into a rule by objectui#6907). @@ -375,7 +446,11 @@ function calculateDateRange(items: any[]): { minDate: string; maxDate: string } * `class X extends Date` that overrides `toString` can hijack `String` and * throw (measured) — the builtin cannot be. This is the one non-primitive * with a spelling the LANGUAGE owns, and it is also the only non-primitive - * the accept set contains. + * the accept set contains. The branch is SELECTED by `isDate` and not by + * `instanceof Date` (objectui#7027): the builtin `toString` throws on a + * receiver that inherits `Date.prototype` without owning the slot, so + * choosing this branch on the prototype chain is what let an impostor crash + * the very sentence that was supposed to name it. * - everything else -> `an array` / `a function` / `an object`, chosen with * `Array.isArray` and `typeof`, which read no author-controlled property. * Deliberately NOT `Object.prototype.toString.call`: that consults @@ -383,10 +458,18 @@ function calculateDateRange(items: any[]): { minDate: string; maxDate: string } * more informative spelling is the non-total one. * * All eight `typeof` results are covered and no branch falls through to author - * code, so the helper is total by construction over every value that reaches - * it. The single reflective operation it performs, `instanceof Date`, is the - * one `isGanttDateType` already performed on the same value to refuse it — so - * this function adds no throw site the accept gate does not already have. + * code. The single reflective operation it performs is the Date test, which is + * the one `isGanttDateType` already performed on the same value to refuse it — + * so this function adds no throw site the accept gate does not already have. + * + * ⚠️ objectui#7027 — that sentence was true and the helper was still not + * total, because the shared test was `instanceof Date` and `instanceof` is not + * total. `Object.create(Date.prototype)` took this function's `Date` branch + * and threw inside `Date.prototype.toString.call`; a `Proxy` with a throwing + * `getPrototypeOf` trap threw inside the operator itself (both measured on + * 7fc5c3c12). Both sites now ask `isDate`, which is total, so the shared + * operation adds no throw site because it HAS none — not because the gate + * absorbed it first. * * ## What this deliberately does NOT do * @@ -407,8 +490,10 @@ function spellGanttDateValue(value: unknown): string { if (typeof value === 'number' || typeof value === 'boolean') return String(value); // The one non-primitive whose spelling the LANGUAGE owns, and the only one - // the accept set contains. `.call` so a subclass cannot hijack it. - if (value instanceof Date) return Date.prototype.toString.call(value); + // the accept set contains. `.call` so a subclass cannot hijack it, and + // `isDate` rather than `instanceof` so a slot-less impostor never reaches + // that builtin — it throws on a receiver without `[[DateValue]]` (#7027). + if (isDate(value)) return Date.prototype.toString.call(value); // Every other non-primitive — named, never rendered. No author code runs. if (Array.isArray(value)) return 'an array'; @@ -612,16 +697,33 @@ type UnusableGanttDate = { path: string; value: unknown }; * none of which throw. Those two classes now take the ordinary refusal path, * and that `symbol` branch is reachable at last. * - * `instanceof Date` is this repo's single idiom for "is a Date" (see - * `packages/core/src/validation/validation-engine.ts` and - * `components/src/renderers/complex/data-table.tsx`); an invalid `Date` object - * passes this gate and is then refused by the parse check below, where it - * belongs. + * ⚠️ ...with ONE gap, closed by objectui#7027 and worth reading as the reason + * this docblock now points at `isDate`. The paragraph above is a claim about + * `new Date`'s ARGUMENT, and it was only as good as the predicate that + * produced it. `instanceof Date` answers "does this inherit from + * `Date.prototype`?", not "is this a `Date`?", so it admitted + * `Object.create(Date.prototype)` — which is not a `Date`, reached `new Date`, + * and threw. Measured on 7fc5c3c12; the reading and the two other impostor + * spellings are on `isDate` above and pinned in + * `timeline-gantt-date-brand-7027.test.tsx`. + * + * ⛔ That is the fourth totality claim in this code path and the third to be + * falsified by the next card's measurement (#6759 -> #6905 -> #6907 -> this). + * The pattern is the lesson: totality asserted in prose is a hypothesis, and + * the only thing that has ever settled it here is an exercised input set. Do + * not answer a future gap with a fifth sentence — add the row to that file. + * + * The ACCEPT SET is untouched by #7027. `isDate` is strictly narrower than + * `instanceof Date` over values that reach it, and everything it newly refuses + * is a value no authored document can carry (ObjectUI metadata is JSON; JSON + * has no way to spell a prototype). An invalid `Date` object owns its slot, so + * it still passes this gate and is still refused by the parse check below, + * where it belongs, with its `Invalid Date` spelling intact. */ const isGanttDateType = (value: unknown): value is string | number | Date => typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)) || - value instanceof Date; + isDate(value); function findUnusableGanttDate( items: any[],