Skip to content

fix(plugin-timeline): spell a refused gantt date by a rule, not by String - #7026

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6907-gantt-date-spelling
Aug 31, 2026
Merged

fix(plugin-timeline): spell a refused gantt date by a rule, not by String#7026
os-sam merged 1 commit into
mainfrom
claude/issue-6907-gantt-date-spelling

Conversation

@claude

@claude claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #6907

spellGanttDateValue fills the {{value}} hole of
timeline.gantt.unusableRange.malformedDate — the alert whose entire job, per
its own docblock, is "to name the value the author actually wrote". Its
String(value) fallback was written when nothing but [object Object]-shaped
values could reach it. This PR replaces that fallback with a rule, recorded
with its ground.

Premise check first

The card describes population 2 as "armed once #6781 lands". #6781 has
landed
85f6a6097 (PR #6905) is on main. So every misleading row below is
live on main today, not pending. The card's ground is stronger than filed, not
weaker.

What was measured on the base (b458300ca), before any change

One row item, a throwaway probe, startDate: '2024-01-01' with endDate varied:

authored endDate diagnostic on main today
[] endDate is , which is not a valid date
['2024-01-01'] endDate is 2024-01-01, ...
[0] endDate is 0, ...
0n endDate is 0, ...
{} endDate is [object Object], ...
{toString: () => '2024-01-01'} endDate is 2024-01-01, ...
new Map() endDate is [object Map], ...
function myFn() {...} the function's source text, newlines and all
{toString() { throw }} 🔴 THREW, uncaught, mid-render
{get [Symbol.toStringTag]() { throw }} 🔴 THREW, uncaught, mid-render
Object.create(null) 🔴 THREW TypeError: Cannot convert object to primitive value

Three faults, not one:

  1. It VANISHES. String([]) is the empty string — the card's filed case,
    and the exact blur the docblock says it quotes strings to avoid.
  2. It LIES. It names a text that is a valid date, or a number that is an
    accepted value. 0n reading as 0 is the worst: 0 alone is a kept
    gantt date under finding(plugin-timeline): non-string gantt dates that coerce to the epoch (0, false, []) still draw a 649-column 1970 axis with no diagnostic #6781.
  3. It THROWS — and this was not in the card. The last three rows are live
    crashes on main. finding(plugin-timeline): two more unusable gantt date ranges — a malformed date still throws, and an inverted author-pinned range silently draws a negative-width bar on no axis #6759 built this helper "total by construction" and
    fix(plugin-timeline): judge gantt dates by TYPE — string, finite number, or Date #6905 made that property load-bearing by putting the type gate before
    new Date. But the gate only stopped new Date from throwing;
    String(value) handed control to author-supplied toString /
    Symbol.toStringTag one line later. The crash class did not go away — it
    moved out of findUnusableGanttDate and into the speller.
    That turns this
    from diagnostic polish into a repair of the totality property both earlier
    cards depend on.

The rule

Spell the value when the LANGUAGE owns its spelling.
Name its TYPE when producing text would run AUTHOR code.

Every primitive has a spelling fixed by the grammar: it is literally the text
the author typed, and it cannot be confused with another value's spelling. A
non-primitive has no such spelling — the only way to get text out of one is to
call toString / Symbol.toPrimitive / Symbol.toStringTag, all of which are
whatever the authored document happens to carry. The diagnostic would be quoting
the very data it is refusing, as if it were trustworthy.

authored before after
[] `` (blank) an array
['2024-01-01'] 2024-01-01 an array
[0] 0 an array
0n 0 0n
{} [object Object] an object
{toString: () => '2024-01-01'} 2024-01-01 an object
new Map() [object Map] an object
function myFn source text a function
{toString() { throw }} 🔴 crash an object
{get [Symbol.toStringTag]() { throw }} 🔴 crash an object
Object.create(null) 🔴 crash an object
'not-a-date' "not-a-date" "not-a-date" (#6759 pin)
'' "" "" (#6759 pin)
undefined undefined undefined (#6759 pin)
null null null (#6770 pin)
Symbol('oops') Symbol(oops) Symbol(oops) (#6781 pin)
false / NaN false / NaN false / NaN
new Date(NaN) Invalid Date Invalid Date

bigint is the one place the rule keeps naming the value where the card
offered a bigint: a bigint is a primitive, its literal syntax is fixed by the
language, and 0n is both what the author typed and unmistakably not the
accepted 0. The rule decided that, not taste.

The four-facet analysis

1. 实际业务需求 — real business need. The population is real and measured,
and the repair the author needs is unwrap the wrapper. An array or a
toString-able object in a date column is what a mapping layer emits for a
to-many relation or a column returned as a list — the same class of fault #6781
measured for false. The path half already does the finding and is always
exact (items[0].items[0].endDate); the author can read their own document at
that path. What they cannot read is why it was refused, and today the sentence
actively denies it — endDate is 2024-01-01, which is not a valid date sends
them to check a date format that is already correct. Naming the type is the half
of the sentence they do not already have.

2. 项目长远合理性 — long-term soundness. This is a rule with a ground, not a
fourth special case: it is derived from a property of the value's kind
(language-owned spelling vs author code), so it closes the whole class rather
than the rows on the card — no later card for Map, for a function, for
Object.create(null). It also restores the totality property #6759 declared
and #6905 made load-bearing, rather than leaving a crash class parked in a
helper that documents itself as total. And it is contract-first in the direction
this repo requires: the refusal stays exactly as ruled, only its report
changes.

3. 防 AI 写错 — making AI-written metadata hard to get wrong. The strongest
facet here. A self-contradictory diagnostic is the worst possible input to an
agent authoring metadata: endDate is 2024-01-01, which is not a valid date
invites it to re-format a date that is already correct, and to loop. endDate is an array names the repair in the sentence. And the crash rows are worse still —
an AI author gets a blank screen with no message at all, the one failure mode
from which no correction can be inferred. Declared = enforced only helps if the
enforcement can say what it enforced.

4. 创业阶段不扩散 — startup scope discipline. This is the smallest change
that closes the class: one helper, no new i18n key, no new file surface, no
recursion, no new dependency, no change to the accept set. The hybrid
("type plus a bounded rendering") is declined — see below — and declining it is
what keeps the change this small.

Why the other two candidates lose

JSON.stringify — refuted, and re-measured here. JSON.stringify(0n) throws
TypeError: Do not know how to serialize a BigInt; it throws again on a cycle
and silently drops undefined members. It would put fault 3 straight back into
the one helper that must not have it. This PR adds no JSON.stringify call.
It remains only where #6759 already had it and where it is total: on a string,
as the quoting.

The hybrid — declined on measurement, not on taste. The rule refuses any
rendering of a non-primitive because rendering reads something off it. Even the
most bounded version imaginable — an element count — is not total:
Array.isArray is true for a Proxy whose length trap throws (measured).
A hybrid would buy a few characters of detail by giving back exactly the
crash-freedom this card is about.

For the same reason the type name is chosen with Array.isArray / typeof and
not Object.prototype.toString.call — the more informative spelling
([object Map]) consults Symbol.toStringTag, which can be a throwing getter
(measured).

Totality — proven, not asserted

All eight typeof results are covered and no branch falls through to author
code. Pin 4 renders each of these and asserts the helper does not throw, still
names the path, and leaks no author-controlled text into the sentence:

an object whose toString throws · whose Symbol.toStringTag getter throws ·
whose valueOf throws · whose Symbol.toPrimitive throws · Object.create(null) ·
a cyclic object · a cyclic array · a Proxy throwing on every property ·
a Date subclass with toString hijacked to throw · 0n ·
{ endDate: undefined } · a symbol · a function · a class constructor · Map ·
Set · RegExp · an array of hostile members.

The single reflective operation the helper performs, instanceof Date, is the
one isGanttDateType already performed on the same value in order to refuse it,
so this function adds no throw site the accept gate does not already have.

Date uses Date.prototype.toString.call rather than String for that reason:
measured byte-identical for every Date owning a [[DateValue]] slot (both the
valid reading and Invalid Date), but a class X extends Date overriding
toString can hijack String and throw — the builtin cannot be.

Constraints the card set, each checked

Verification

Union run on the final commit 52dec0a08:

  • pnpm exec vitest run packages/plugin-timeline --maxWorkers=218 files,
    211 tests, all passed
    (25 of them new in this PR).
  • pnpm --filter @object-ui/plugin-timeline type-check — clean
    (tsc --noEmit && tsc -p tsconfig.test.json; --listFiles confirms both the
    new test file and renderer.tsx are inside that program, so "type-check
    clean" is a statement about the code that changed).
  • eslint on both changed files — 0 errors, 0 fatal, 19 warnings, all
    pre-existing categories (no-explicit-any, react-refresh) matching the
    sibling test files.
  • Control-byte scan of all three changed files — clean.

Declared lint narrowing. eslint was run on the two changed files rather than
repo-wide, and the narrowing is measured rather than assumed: (1) the diff
touches exactly two lintable files and no eslint config; (2) --format json
reports 2 files linted, 0 errors; (3) the flat config sets no
project / projectService, so type-aware linting is off and a file's verdict
depends only on its own contents plus the shared config — this diff therefore
cannot move the verdict of any untouched file. CI runs the full farm regardless.

The before/after readings above are from a throwaway probe rendered through
TimelineRenderer, run on the unmodified base and again on this branch; the
probe file is not committed, and every reading it produced is now covered by a
committed pin.

Out of scope, recorded

Object.create(Date.prototype) — a slot-less Date impostor — passes
isGanttDateType's instanceof Date and then crashes inside
findUnusableGanttDate's new Date(value), before reaching this helper.
That is a boundary of #6781's accept gate, not of the speller, and it is
unreachable from JSON metadata or from any data adapter (both produce real
Dates or plain values). Left untouched deliberately; noted here so a reviewer
can disagree.


Generated by Claude Code

…tring`

`spellGanttDateValue` fills the `{{value}}` hole of the unusable-gantt-date
alert, whose stated job is to name the value the author actually wrote. Its
`String(value)` fallback was written when nothing but `[object Object]`-shaped
values could reach it. #6905's type rule routes the whole non-date type space
through it, and it failed three ways — measured on b458300:

  endDate: []                          -> "endDate is , which is not a valid date"
  endDate: ['2024-01-01']              -> "endDate is 2024-01-01, ..."
  endDate: [0]                         -> "endDate is 0, ..."
  endDate: 0n                          -> "endDate is 0, ..."
  endDate: {toString: () => '2024-01-01'} -> "endDate is 2024-01-01, ..."
  endDate: {toString() { throw }}      -> THREW, uncaught, mid-render
  endDate: {get [Symbol.toStringTag]() { throw }} -> THREW, uncaught, mid-render
  endDate: Object.create(null)         -> THREW TypeError

It VANISHES, it LIES (naming a text that IS a valid date, or a number that IS
an accepted one — `0` is a kept gantt date), and it THROWS. The third is the
one that matters: #6759 built this helper "total by construction" and #6905
made that property load-bearing, but the type gate only stopped `new Date`
from throwing. `String(value)` handed control to author-supplied `toString` /
`Symbol.toStringTag` one line later, so the crash class did not go away — it
moved out of `findUnusableGanttDate` and into the speller.

The rule, now recorded on the helper with its ground:

    Spell the value when the LANGUAGE owns its spelling.
    Name its TYPE when producing text would run AUTHOR code.

Every primitive is spelled as the author typed it, `bigint` included — `0n`
keeps its `n` rather than reading as the accepted `0`. A `Date` uses
`Date.prototype.toString.call`, byte-identical to `String` for every Date with
a `[[DateValue]]` slot but not hijackable by a subclass override. Everything
else is named by type — `an array`, `a function`, `an object` — chosen with
`Array.isArray` and `typeof`, which read no author-controlled property.

`JSON.stringify` is refuted, not overlooked: `JSON.stringify(0n)` throws, and
so does a cycle. A bounded rendering is refused on the same ground — even an
element count is not total, because `Array.isArray` is true of a Proxy whose
`length` trap throws.

Unchanged, and pinned: the accept/reject set (#6781's ruling), the `undefined`
/ `null` / quoted-string spellings (#6759, #6770), the shared inverted-range
diagnostic, and the i18n keys — the article rides in the existing `{{value}}`
hole, so `packages/i18n`'s ten locale packs are not opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 45 chunks) 3180.2 KB 3222.7 KB
Main entry chunk (gzip) 143.6 KB 350 KB
Entry file index-BN8w2BQN.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 12.46KB 4.71KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 512.08KB 116.42KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 177.67KB 49.45KB
fields (index.js) 243.64KB 61.64KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.53KB 45.18KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 247.26KB 63.17KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.11KB 32.61KB
plugin-gantt (index.js) 165.21KB 40.37KB
plugin-grid (index.js) 202.08KB 54.61KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 29.27KB 8.44KB
plugin-tree (index.js) 8.98KB 3.08KB
plugin-view (index.js) 85.79KB 21.10KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-timeline): the unusable-gantt-date diagnostic spells an array or an object either BLANKLY or as a text that looks like a valid date

1 participant