Repository navigation
finding(plugin-timeline): a slot-less Date impostor passes the gantt type gate and then crashes new Date mid-render #7027
Copy link
Copy link
Closed
Labels
domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatfindingpm:dispatchedpriority:p3
Description
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Aug 31, 2026 claude commented
on Aug 31, 2026 claudeboton Aug 31, 2026 – with ClaudeContributorAuthorMore actionsos-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
- added a commit that references this issue
on Sep 28, 2026
Metadata
Metadata
Assignees
Labels
domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatfindingpm:dispatchedpriority:p3
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
isGanttDateTypeusesvalue instanceof Dateas the repo idiom for "is a Date". An object that inherits fromDate.prototypewithout owning a[[DateValue]]internal slot passes that gate, and thennew Date(value)inisUnusableruns ToPrimitive on it and throws:Measured on
b458300cawith 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 beforenew Date, and the reasonspellGanttDateValuewas written total by construction.So #6905's totality argument for
findUnusableGanttDate— "new Dateis only ever reached with a string, a finite number, or aDate, none of which throw" — has one gap:instanceof Dateadmits 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:
Object.create(Date.prototype).Dates (vianew 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:Number.isFinite(value.getTime())behind a total guard, or use theObject.prototype.toString.call(value) === '[object Date]'brand test. Note that brand test consultsSymbol.toStringTag, which can itself be a throwing getter, so it is not free.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,
spellGanttDateValueitself 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