Skip to content

finding(plugin-timeline): a slot-less Date impostor passes the gantt type gate and then crashes new Date mid-render #7027

Description

@claude

Found while implementing #6907 (the gantt-date spelling rule, PR #7026). Not repaired there: it sits in findUnusableGanttDate's type gate, one function upstream of the speller that card is about, and it is #6781's surface rather than #6907's.

What is wrong

isGanttDateType uses value instanceof Date as the repo idiom for "is a Date". An object that inherits from Date.prototype without owning a [[DateValue]] internal slot passes that gate, and then new Date(value) in isUnusable runs ToPrimitive on it and throws:

Object.create(Date.prototype) instanceof Date          -> true   (passes the type gate)
new Date(Object.create(Date.prototype)).getTime()      -> THREW TypeError:
    Method Date.prototype.toString called on incompatible receiver [object Object]

Measured on b458300ca with a throwaway node probe. The crash is uncaught and mid-render, so the author gets a blank screen where a named diagnostic belongs — the exact failure mode #6781 set out to remove when it put the type gate before new Date, and the reason spellGanttDateValue was written total by construction.

So #6905's totality argument for findUnusableGanttDate — "new Date is only ever reached with a string, a finite number, or a Date, none of which throw" — has one gap: instanceof Date admits a Date-shaped object that is not a Date.

Why it was left alone rather than fixed in #6907

Judged not reachable from an authored document, which is why this is filed as an observation rather than repaired inline:

  • ObjectUI metadata is JSON, and JSON cannot express Object.create(Date.prototype).
  • Data adapters produce real Dates (via new Date(...)), plain objects, strings or numbers — all of which own their slots or are refused cleanly.

It needs hand-written JS that deliberately constructs a slot-less Date impostor. Recording it because #6781's totality property is stated out loud in the code, and a stated invariant with a known gap should be either closed or documented rather than quietly untrue.

Suggested scope if picked up

packages/plugin-timeline/src/renderer.tsx, isGanttDateType / isUnusable. Two routes, both cheap:

  1. Tighten the gate so it tests the slot, not just the prototype chain — e.g. judge Number.isFinite(value.getTime()) behind a total guard, or use the Object.prototype.toString.call(value) === '[object Date]' brand test. Note that brand test consults Symbol.toStringTag, which can itself be a throwing getter, so it is not free.
  2. Decide the invariant is "reachable inputs only" and say so in the docblock, narrowing the totality claim to the values an authored document can carry.

Route 2 may well be the right answer under startup scope discipline; the point of this card is that the choice should be made rather than inherited. Whichever wins, spellGanttDateValue itself is unaffected — after #6907 it is total over every value that reaches it.

Refs: #6907 / PR #7026 (the spelling rule) - #6781 / PR #6905 (the type rule and its totality argument) - #6759 / #6770 (the pinned spellings)


Generated by Claude Code

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 31, 2026
  2. claude commented on Aug 31, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 7027,
      "branch": "claude/issue-7027-gantt-date-brand-gate",
      "pr_url": "https://github.com/objectstack-ai/objectui/pull/7037",
      "status": "done",
      "premise_still_valid": true,
      "route_chosen": "Route 1 — close the gap in code — but with a THIRD implementation the card did not consider. Both route-1 spellings the card proposed were measured and refuted (each is pinned as a red row). What ships is a module-local `isDate` that invokes the BUILTIN `Date.prototype.getTime` with `.call`: it reads the receiver's [[DateValue]] internal slot and nothing else, so no author getter runs, no @@toStringTag is consulted, no proxy trap fires, and a subclass cannot hijack it. The slot is observable no other way, so the catch IS the read, not error handling over a fallible operation. Route 2 was rejected on two grounds: (a) it swaps a false LOCAL claim for a true-today GLOBAL one (reachability spans every adapter, helper and future deserializer) which is strictly harder to keep and is the exact shape falsified three times already; (b) mechanically, under route 2 Object.create(Date.prototype) still crashes, so the required pinned set could not assert a named diagnostic for it.",
      "four_facet_analysis": {
        "real_business_need": "LOW, and stated honestly — no authored document reaches this; I verified the reachability analysis rather than accepting it and it holds. The need served is the next engineer's, not an author's: #6905's totality argument is written out loud in the code and is load-bearing for the ORDER of two operations, so a reader who trusts it and reorders them ships a crash. Cost of being right: 8 lines.",
        "long_term_soundness": "DECIDING FACET. The path now carries a local, checkable property ('this predicate cannot throw') instead of a global, unverifiable one ('no input of this class can arrive'). Contract-first respected: nothing became tolerant, no consumer gained a fallback, #6781's producer-side accept set is untouched — the change makes an existing refusal ARRIVE instead of crashing on the way.",
        "hard_for_ai_to_get_wrong": "STRONGEST argument for route 1 over route 2. A narrowed prose claim is text a later agent can silently re-widen with nothing going red. A total predicate plus an exercised adversarial set is structural: each of the two obvious 'cleanups' of isDate has a dedicated failing test with the reason written on it. Both docblocks now say in the imperative what to do with the next gap — add the row to that file, do not write a fifth sentence.",
        "startup_scope_discipline": "Net +8 lines of code, one module-local unexported helper, no new capability, no spec key, no new i18n key, no locale packs opened, no accept-set adjudication. The suite is the bulk of the diff and is the deliverable the card asked for. On this facet I deliberately did NOT fix the one remaining non-total operation I found on this path — filed as #7036 instead, because #6907 chose Array.isArray deliberately with a measured argument, so replacing it is an adjudication, not a mechanical fix."
      },
      "reachability_verified": {
        "holds": true,
        "any_reachable_path_found": false,
        "evidence": "Swept on 7fc5c3c12, not inherited from the card. (1) NO CONSTRUCTION SITE: `Object.create(` appears nowhere in packages/*/src or apps/*/src outside comments — every hit is prose about __proto__ key hazards; no setPrototypeOf in source at all. (2) NO PROTOTYPE-RECONSTRUCTING DESERIALIZER: no superjson, devalue, flatted, no JSON.parse reviver, no date-revival helper anywhere in source; the only structural cloner is structuredClone, which produces a REAL Date (own slot, receiving realm), not an impostor. (3) JSON CANNOT SPELL IT: ObjectUI metadata is JSON and JSON has no syntax for a prototype. Conclusion: p3 grade stands, this is a latent-invariant repair, not a live author-facing bug — which is why the argument leans on the long-term and anti-AI-error facets rather than on business need."
      },
      "adversarial_pins": [
        { "input": "Object.create(Date.prototype) — inherits the prototype, owns no [[DateValue]] slot", "result": "base 7fc5c3c12: THREW TypeError 'Method Date.prototype.toString called on incompatible receiver [object Object]' in isUnusable (renderer.tsx:648). At e44f8ad82: NAMED — role=alert, diagnostic contains the authored path and 'is an object', axis empty, 0 bars." },
        { "input": "that same impostor behind a Proxy that throws on every get", "result": "base: THREW (the get trap, reached via ToPrimitive inside new Date). At e44f8ad82: NAMED — path + 'is an object', 0 bars." },
        { "input": "Proxy with a throwing getPrototypeOf trap — NOT on the card; instanceof is not total on its own terms", "result": "base: THREW inside the instanceof operator itself (isGanttDateType, renderer.tsx:624). At e44f8ad82: NAMED — path + 'is an object', 0 bars." },
        { "input": "Proxy throwing on every get", "result": "NAMED on both trees — 'is an object'. Kept as a REGRESSION pin: the new gate must not start reading properties off the value." },
        { "input": "class X extends Date { getTime() { throw } } — a REAL Date whose getTime is hijacked", "result": "ACCEPTED on both trees, chart drew: 1 bar, axis ['Jan 2024','Feb 2024','Mar 2024']. This is a CONTROL, not a refusal — super() gave it the slot so #6781's accept set contains it. It is the row that refutes Number.isFinite(value.getTime()), which would refuse a real Date by dying on it." },
        { "input": "{ get [Symbol.toStringTag]() { throw } }", "result": "NAMED on both trees — 'is an object'. CONTROL that refutes Object.prototype.toString.call(value) === '[object Date]': that performs Get(O, @@toStringTag) UNCONDITIONALLY (ES2015 19.1.3.6 step 16), so the brand test runs this getter and throws." },
        { "input": "new Date('2024-01-01') — a real, valid Date", "result": "ACCEPTED on both trees — no diagnostic, 1 bar, axis Jan/Feb/Mar 2024." },
        { "input": "new Date(NaN) — a real, invalid Date", "result": "NAMED on both trees, still spelled 'Invalid Date'. Load-bearing: it owns its slot so it must PASS the type gate and be refused one step later by the parse check. A brand test that refused it would change the spelling an author sees." },
        { "input": "accept/refuse control row", "result": "string / finite number / real Date all ACCEPTED (1 bar each); null / undefined / boolean all REFUSED (path named, 0 bars) — exactly where #6781 left them." }
      ],
      "note_on_the_deliverable_template": "The dispatch said each pinned input must produce a NAMED DIAGNOSTIC, never a throw. Two of the six named inputs are valid Dates (a real Date, and the hijacked-getTime subclass, which owns its slot), so forcing a diagnostic for them would CHANGE THE ACCEPT SET, which deliverable 4 forbids. The suite therefore asserts each input reaches exactly one of ACCEPTED or NAMED, and never THREW. Stating this rather than forcing the template.",
      "crash_reproduced_on_base": {
        "how": "The pinned suite was written against UNMODIFIED 7fc5c3c12 and run there BEFORE renderer.tsx was touched, via the root-relative vitest form under the shared verify lock: `pnpm vitest run packages/plugin-timeline/src/__tests__/timeline-gantt-date-brand-7027.test.tsx`.",
        "observed": "Test Files 1 failed (1) · Tests 3 failed | 6 passed (9). Row 1: TypeError: Method Date.prototype.toString called on incompatible receiver [object Object] — isUnusable renderer.tsx:648:45 -> findUnusableGanttDate:655:13 -> TimelineRenderer:982:28. Row 2: Error: get trap — isUnusable renderer.tsx:648:45. Row 3: Error: getPrototypeOf trap — isGanttDateType renderer.tsx:624:3. All three uncaught mid-render. The 6 passing rows are the controls, confirming the accept set on the base."
      },
      "accept_set_unchanged": true,
      "pinned_spellings_unmoved": true,
      "speller_untouched": false,
      "speller_edit_explained": "FALSE, and this is the one deviation from the dispatch that needs review. spellGanttDateValue's BRANCHES are untouched — I changed none of them and no value's spelling moved. What changed is which predicate SELECTS its Date branch: it was `value instanceof Date`, which carries the identical defect. ABLATION (fix committed first, then ONLY the speller's selector reverted to instanceof, gate kept fixed; mutation confirmed on disk by grep counts in both directions plus a blob-hash difference vs HEAD): the SAME three tests fail, with the same class of crash, now at `spellGanttDateValue renderer.tsx:496:61` instead of isUnusable:648 — 'Tests 3 failed | 6 passed (9)'. The crash RELOCATES one function downstream instead of closing, which is precisely the shape #6907 recorded when it found #6905 had moved the crash rather than removed it. So the dispatch's premise ('total over everything reaching it after #7026') was true BEFORE this fix and false AFTER it: fixing the gate changes what reaches the speller. RESTORE LEG proved by state, not exit code: blob hash 648f778427f41b4c4ee908bd96fb954aeb567632 equals HEAD:packages/plugin-timeline/src/renderer.tsx and `git diff HEAD` is empty. The two predicates disagree only on slot-less impostors (which crashed before, so nothing reached the speller) and cross-realm Dates (which no path here produces); the full 204-test suite is the control.",
      "checks_run": [
        { "name": "unit — affected package, final tree", "command": "pnpm vitest run packages/plugin-timeline/src/__tests__/", "result": "PASS — 'Test Files 16 passed (16) · Tests 204 passed (204)' at e44f8ad82 (clean tree, run AFTER the final commit). #6759/#6770/#6781/#6907 pinned spellings and the inverted-range message all still green." },
        { "name": "crash reproduction on base", "command": "same suite on unmodified 7fc5c3c12", "result": "FAIL as intended — 'Tests 3 failed | 6 passed (9)', three uncaught TypeErrors mid-render." },
        { "name": "ablation — is the speller edit forced?", "command": "speller selector reverted to instanceof, gate kept fixed", "result": "FAIL — 'Tests 3 failed | 6 passed (9)', crash relocated to spellGanttDateValue renderer.tsx:496:61. Mutation and restore both proved on disk." },
        { "name": "typecheck", "command": "pnpm --filter @object-ui/plugin-timeline run type-check", "result": "PASS — exit 0 on 'tsc --noEmit && tsc -p tsconfig.test.json'. FIRST ATTEMPT FAILED with 16 TS2307 'Cannot find module @object-ui/*' — the unbuilt-dependency-closure trap; fixed by `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-timeline^...' run build` (10 packages, all Done), then green." },
        { "name": "typecheck actually covers the new test file", "command": "tsc -p tsconfig.test.json --noEmit --listFiles | grep -c timeline-gantt-date-brand-7027", "result": "1 — MEASURED, not assumed (0 would have meant the typecheck said nothing about the new file)." },
        { "name": "eslint (plain form, no --no-inline-config)", "command": "pnpm exec eslint packages/plugin-timeline", "result": "PASS — 26 files, 0 errors, 97 pre-existing warnings (my two files contribute 1 no-explicit-any warning, the same `as any` idiom the sibling pinned suites use)." },
        { "name": "check:control-bytes", "command": "pnpm run check:control-bytes", "result": "PASS — 'check-control-bytes: OK (scanned 5837 tracked text file(s); skipped 85 binary)'. Plus a self-scan: grep -naP over my three files found no control bytes." },
        { "name": "check:vi-mock-specifiers", "command": "pnpm run check:vi-mock-specifiers", "result": "PASS — 'OK (4045 tracked source file(s), 2323 test-named; 509 carry a mock ...)'." },
        { "name": "check:vi-mock-inherit", "command": "pnpm run check:vi-mock-inherit", "result": "PASS — 'OK (112 call site(s) on @object-ui/react judged (112 inherit, 0 auto-mocked))' — my new vi.mock uses the importOriginal spread." },
        { "name": "check:shell-escape-residue", "command": "pnpm run check:shell-escape-residue", "result": "PASS — 'OK (0 occurrence(s) outside a fence)'." },
        { "name": "changeset:check", "command": "pnpm run changeset:check", "result": "PASS — 'All workspace packages are in the changeset fixed group' + 'No changeset declares a major bump'." }
      ],
      "declared_narrowing": "Repo-wide `pnpm lint` was NOT run locally; the affected package was. Three pieces of evidence, per contract: (1) the population came from eslint's OWN config resolution, not my guess — `pnpm exec eslint packages/plugin-timeline` resolved the file set itself; (2) the count is read from --format json: 26 files, 0 errors; (3) type-aware linting is NOT enabled (eslint.config.js declares no projectService and no parserOptions.project), so a file's verdict depends only on its own bytes plus the shared config, and this diff touches 3 files, none of them eslint.config.js — so no untouched file's verdict can move. CI runs the full farm regardless.",
      "not_owed": "No consumer sweep — isDate is module-local and unexported, no package boundary or .d.ts changed. No i18n gates — no t() call site or key added/removed; the refusal reuses timeline.gantt.unusableRange.malformedDate and the ten locale packs are untouched. No skip-changeset label — a real changeset ships (patch to @object-ui/plugin-timeline).",
      "out_of_scope_findings": [
        "filed as #7036: spellGanttDateValue's `Array.isArray` is now the LAST non-total operation on this path — a REVOKED Proxy throws TypeError 'Cannot perform IsArray on a proxy that has been revoked'. NOT a regression (it crashed inside instanceof before this PR and inside Array.isArray after — same render, different line), not reachable, and #6907 chose Array.isArray deliberately with a measured argument, so the correct replacement shape is an adjudication rather than a mechanical fix. Measured with a node probe replicating both functions' branch bodies verbatim; not measured in-render. Dedupe: REST repo-scoped read of the 17 open `finding` issues (single page, complete), none covering it."
      ],
      "mcp_calls": "4 — issue_read(get), issue_read(get_comments), add_issue_comment(claim), issue_write(create #7036). Everything else went over repo-scoped REST: the global /search/issues path is blocked in this seat ('sessions are bound to their configured repositories'), so dedupe used the repo-scoped labels=finding listing plus a local grep rather than one targeted MCP search.",
      "claim_comment_url": "https://github.com/objectstack-ai/objectui/issues/7027#issuecomment-5479619088",
      "blockers": []
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatfindingpm:dispatchedpriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions