fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal
Sep 2, 2026
Merged

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror#7367
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#7164
Clause-②: yes — contract review required before release

Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the malformedDate copy; AND tighten the zod mirror so null rows and non-array items are refused by validate before a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

What changed

Render-time door (packages/plugin-timeline/src/renderer.tsx). One normalizer, classifyGanttRows(items), reads the raw shape ONCE and returns either { ok: true, rows } — a readonly GanttRow[] whose items is an array by construction — or { ok: false, path, value }. Three refusals, in walk order: items not an array → items; a null / undefined row → items[i]; a truthy non-array row.itemsitems[i].items. The three readers the card named — findUnusableGanttDate, calculateDateRange and the render loop — now all consume the verdict; none re-reads schema.items. The refusal renders the existing role="alert" surface (#6759's element, same data-testid) through the new key, with {{path}} and {{value}} (the value spelled by spellGanttDateValue, so no author code runs). onItemClick still receives the author's own row object (pinned).

The keytimeline.gantt.unusableRange.malformedRow, placed directly after malformedDate inside the existing unusableRange block in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack's malformedDate line; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, as malformedDate has). English:

Unusable gantt rows — {{path}} is {{value}}, which is not a row shape. A gantt draws items as a list of rows, every row as an object with a label and its own items, and every row's items as a list of bars; a null, a number, a string or a plain object in any of those places cannot be drawn.

Author-time door (packages/types/src/zod/data-display.zod.ts). TimelineSchema.items is now z.array(TimelineRowSchema) where the (non-exported, per the parity-registry convention) row schema is z.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row's items, when present, an array. Nothing else narrowed — feed items carry no items key and parse as before; bars stay z.any(). The TS twin (data-display.ts) keeps items?: any[] (its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[] is assignable to the narrowed input), so KnownDrift is untouched.

Pins. New timeline-gantt-malformed-row-7164.test.tsx (22 tests: the card's table, controls, order, onItemClick identity, the key); new timeline-items-row-shape-7164.test.ts in packages/types (accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin's pin 6 (which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of its pin 5 moved with the reads they exercise (below). The 6907 pin's "no key was added" it title is corrected to what it asserts.

Changeset.changeset/7164-gantt-malformed-row-refusal.md: @object-ui/plugin-timeline patch · @object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) · @object-ui/i18n patch.

Contract-review pack

(a) The card's table, re-run on 8e1dc8274 through the real TimelineRenderer

RED first, on a67abdc88 (same probe, before any edit):

items: [null] THREW renderer.tsx:287:10 (row.items)
items: [{ items: 5 }] THREW renderer.tsx:287:23 ((row.items || []).flatMap)
items: [{ items: {} }] THREW renderer.tsx:287:23
items: [{ items: true }] THREW renderer.tsx:287:23
items: [{ items: {length:1, 0:{dates}} }] THREW renderer.tsx:287:23
items: {} THREW renderer.tsx:286:26 (items.flatMap)
items: 'x' THREW renderer.tsx:286:26
items: 5 THREW renderer.tsx:286:26
items: [{ items: 'x' }] NAMED items[0].items[0].startDate is undefined, which is not a valid date
CONTROL an ordinary row DREW bars=1 axisCells=3
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

GREEN, on 8e1dc8274 (the new pin asserts each line; 22/22 pass):

items: [null] REFUSED malformedRow "items[0] is null, which is not a row shape"
items: [{ items: 5 }] REFUSED malformedRow "items[0].items is 5, …"
items: [{ items: {} }] REFUSED malformedRow "items[0].items is an object, …"
items: [{ items: true }] REFUSED malformedRow "items[0].items is true, …"
items: [{ items: {length:1, 0:{dates}} }] REFUSED malformedRow "items[0].items is an object, …"
items: {} REFUSED malformedRow "items is an object, …"
items: 'x' REFUSED malformedRow "items is \"x\", …"
items: 5 REFUSED malformedRow "items is 5, …"
items: [{ items: 'x' }] REFUSED malformedRow "items[0].items is \"x\", …" ⚠ CHANGED — see deviation 1
CONTROL an ordinary row DREW bars=1 axisCells=3 (axis Jan/Feb/Mar 2024, unchanged)
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT malformedDate's. Order pinned: a null row at items[1] wins over an unparseable date at items[2]; a well-formed row with a bad date still takes malformedDate unchanged.

(b) The mirror's accept set — safeParse, origin/main (a67abdc88) vs head (8e1dc8274)

Measured by a probe that rebuilt the old declaration (z.array(z.any()).optional()) on the same base:

input main head
items: [null] accept REFUSE @ items[0] (expected object, received null)
items: [{ items: 5 }] accept REFUSE @ items[0].items (expected array, received number)
items: [{ items: {} }] accept REFUSE @ items[0].items (expected array, received object)
items: [{ items: { length: 1, 0: {…} } }] accept REFUSE @ items[0].items (expected array, received object)
items: {} REFUSE REFUSE @ items
items: 'x' REFUSE REFUSE @ items
items: [{ items: 'x' }] accept REFUSE @ items[0].items (expected array, received string)
items: [] accept accept
items: [{ label: 'R' }] accept accept
items: [{ items: null }] accept REFUSE @ items[0].items (expected array, received null)
items: [0] accept REFUSE @ items[0] (expected object, received number)
items: [[]] accept REFUSE @ items[0] (expected object, received array)
a feed-variant items array accept accept
an ordinary gantt row accept accept

Three corners where validate is now stricter than the renderer — [{ items: null }], [0], [[]] — are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a document validate admits, and is only ever more lenient than validate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.

(c) Fixture census — every in-repo type: 'timeline' document through the tightened mirror

12 documents, 0 new refusals (each accept on main and on head):

examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json
packages/types/examples/data-display-examples.json#examples.timeline
content/docs/plugins/plugin-timeline.mdx fences at lines 36, 168, 203, 246, 327, 342 (object literal extracted by brace-matching, evaluated)
content/docs/api/schema-reference.md inline documents at lines 668, 1093

Population: grep -rl over examples/, packages/*/catalog (none exist), content/docs, packages/*/examples and the schema-catalog tests for type: 'timeline' / "type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned in timeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.

Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed

Committed first; each leg mutated renderer.tsx with an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored with git checkout HEAD -- path proven by git hash-object equal to the HEAD blob (36abf53e…) and an empty git diff HEAD, trap-guarded, absolute paths. No dist is involved (the pin imports ../renderer; vitest aliases workspace packages to src), so no rebuild leg.

  • Leg 1 — drop the items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crashitems: {} / 'x' / 5 rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.
  • Leg 2 — drop the null-row refusal: 3 red / 19 green. Direction: relocation into the normalizerTypeError: Cannot read properties of null (reading 'items') at renderer.tsx:345:26 (classifyGanttRows), exactly the card's relocation pattern.
  • Leg 3 — drop the truthy-non-array row.items refusal: 7 red / 15 green. Direction: relocation into calculateDateRangeTypeError: row.items.flatMap is not a function at renderer.tsx:367:25, and the 'x' row falling back to the DATE copy.

Exotic-class pins that moved (stated, exercised)

Array.isArray in classifyGanttRows runs before U1 (items.length) and U4 (rowItems.length) can. A revoked Proxy dies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throw Cannot perform 'IsArray' on a proxy that has been revoked (pin 4's class) instead of 'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks on findUnusableGanttDate, calculateDateRange, spellGanttDateValue and the gantt branch updated to say what is true now.

Verification (final commit 8e1dc8274)

  • Union, run AFTER the final commit, head echoed by the run: pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/Test Files 168 passed (168) · Tests 2689 passed (2689); os-verify-lock: VERDICT command-exit 0.
  • Type-checks (dependency closure built first, pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." build exit 0): pnpm --filter @object-ui/plugin-timeline run type-check (echoed tsc --noEmit && tsc -p tsconfig.test.json) EXIT=0; @object-ui/types (echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0; @object-ui/i18n EXIT=0. --listFiles on the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.
  • ESLint (--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-pattern no-explicit-any / react-refresh classes; no new rule class).
  • Gates: check-changeset-presence ✅ (16 source files of 3 released packages, 1 changeset declared) · check-changeset-no-major ✅ · check-changeset-fixed ✅ · check:control-bytes ✅ (6056 files) · check:i18n-keys ✅ (every call-site key resolves; 2907 en keys) · check:i18n-drift ✅ (0 en values changed, 1 key added — parity's business, and all-locales-key-parity is green) · check:i18n-dead-keys report unchanged · check:vi-mock-specifiers ✅ · check:vi-mock-inherit ✅.
  • Governed-surface predicate (objectstack/scripts/pm/check-governed-merges.mjs --test on the final 19-path list): 0 of 19 path(s) hit the registerNOT governed.
  • check:eager-closure: NOT MEASURED (needs a full apps/console build; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; the framework chunk's headroom in scripts/check-eager-closure-budget.mjs is 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.

Deviations from the dispatch (each declared, none silent)

  1. The NAMED row's outcome changed.items: [{ items: 'x' }] was NAMED through malformedDate on main by accident (a string is index-readable). The ruling's door is "a row whose items is a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED through malformedRow naming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.
  2. Hole named {{value}}, not {{fault}}. The sibling key in the same block uses {{path}} / {{value}} and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.
  3. ZONE 2 item 2's "row null/non-object" narrowed to null/undefined. ZONE 1 6(a) pins items: [0] and [[]] as drawing CONTROLs, and the ruling names null rows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0], ['x'], [true], [[]]) draws an unlabelled empty row silently — while validate now refuses it #7364) rather than decided here.
  4. The plugin's provider-less default table got the same key (useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror of en for the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.
  5. A third row was added to revokedPositions typing in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.

Out of scope, filed

ObjectTimeline.tsx is read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho


Generated by Claude Code

… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3167.3 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-Eyz6MvMH.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.59KB117.40KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)208.92KB56.59KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

2 participants

@os-litant@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal
Sep 2, 2026
Merged

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror#7367
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#7164
Clause-②: yes — contract review required before release

Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the malformedDate copy; AND tighten the zod mirror so null rows and non-array items are refused by validate before a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

What changed

Render-time door (packages/plugin-timeline/src/renderer.tsx). One normalizer, classifyGanttRows(items), reads the raw shape ONCE and returns either { ok: true, rows } — a readonly GanttRow[] whose items is an array by construction — or { ok: false, path, value }. Three refusals, in walk order: items not an array → items; a null / undefined row → items[i]; a truthy non-array row.itemsitems[i].items. The three readers the card named — findUnusableGanttDate, calculateDateRange and the render loop — now all consume the verdict; none re-reads schema.items. The refusal renders the existing role="alert" surface (#6759's element, same data-testid) through the new key, with {{path}} and {{value}} (the value spelled by spellGanttDateValue, so no author code runs). onItemClick still receives the author's own row object (pinned).

The keytimeline.gantt.unusableRange.malformedRow, placed directly after malformedDate inside the existing unusableRange block in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack's malformedDate line; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, as malformedDate has). English:

Unusable gantt rows — {{path}} is {{value}}, which is not a row shape. A gantt draws items as a list of rows, every row as an object with a label and its own items, and every row's items as a list of bars; a null, a number, a string or a plain object in any of those places cannot be drawn.

Author-time door (packages/types/src/zod/data-display.zod.ts). TimelineSchema.items is now z.array(TimelineRowSchema) where the (non-exported, per the parity-registry convention) row schema is z.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row's items, when present, an array. Nothing else narrowed — feed items carry no items key and parse as before; bars stay z.any(). The TS twin (data-display.ts) keeps items?: any[] (its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[] is assignable to the narrowed input), so KnownDrift is untouched.

Pins. New timeline-gantt-malformed-row-7164.test.tsx (22 tests: the card's table, controls, order, onItemClick identity, the key); new timeline-items-row-shape-7164.test.ts in packages/types (accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin's pin 6 (which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of its pin 5 moved with the reads they exercise (below). The 6907 pin's "no key was added" it title is corrected to what it asserts.

Changeset.changeset/7164-gantt-malformed-row-refusal.md: @object-ui/plugin-timeline patch · @object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) · @object-ui/i18n patch.

Contract-review pack

(a) The card's table, re-run on 8e1dc8274 through the real TimelineRenderer

RED first, on a67abdc88 (same probe, before any edit):

items: [null] THREW renderer.tsx:287:10 (row.items)
items: [{ items: 5 }] THREW renderer.tsx:287:23 ((row.items || []).flatMap)
items: [{ items: {} }] THREW renderer.tsx:287:23
items: [{ items: true }] THREW renderer.tsx:287:23
items: [{ items: {length:1, 0:{dates}} }] THREW renderer.tsx:287:23
items: {} THREW renderer.tsx:286:26 (items.flatMap)
items: 'x' THREW renderer.tsx:286:26
items: 5 THREW renderer.tsx:286:26
items: [{ items: 'x' }] NAMED items[0].items[0].startDate is undefined, which is not a valid date
CONTROL an ordinary row DREW bars=1 axisCells=3
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

GREEN, on 8e1dc8274 (the new pin asserts each line; 22/22 pass):

items: [null] REFUSED malformedRow "items[0] is null, which is not a row shape"
items: [{ items: 5 }] REFUSED malformedRow "items[0].items is 5, …"
items: [{ items: {} }] REFUSED malformedRow "items[0].items is an object, …"
items: [{ items: true }] REFUSED malformedRow "items[0].items is true, …"
items: [{ items: {length:1, 0:{dates}} }] REFUSED malformedRow "items[0].items is an object, …"
items: {} REFUSED malformedRow "items is an object, …"
items: 'x' REFUSED malformedRow "items is \"x\", …"
items: 5 REFUSED malformedRow "items is 5, …"
items: [{ items: 'x' }] REFUSED malformedRow "items[0].items is \"x\", …" ⚠ CHANGED — see deviation 1
CONTROL an ordinary row DREW bars=1 axisCells=3 (axis Jan/Feb/Mar 2024, unchanged)
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT malformedDate's. Order pinned: a null row at items[1] wins over an unparseable date at items[2]; a well-formed row with a bad date still takes malformedDate unchanged.

(b) The mirror's accept set — safeParse, origin/main (a67abdc88) vs head (8e1dc8274)

Measured by a probe that rebuilt the old declaration (z.array(z.any()).optional()) on the same base:

input main head
items: [null] accept REFUSE @ items[0] (expected object, received null)
items: [{ items: 5 }] accept REFUSE @ items[0].items (expected array, received number)
items: [{ items: {} }] accept REFUSE @ items[0].items (expected array, received object)
items: [{ items: { length: 1, 0: {…} } }] accept REFUSE @ items[0].items (expected array, received object)
items: {} REFUSE REFUSE @ items
items: 'x' REFUSE REFUSE @ items
items: [{ items: 'x' }] accept REFUSE @ items[0].items (expected array, received string)
items: [] accept accept
items: [{ label: 'R' }] accept accept
items: [{ items: null }] accept REFUSE @ items[0].items (expected array, received null)
items: [0] accept REFUSE @ items[0] (expected object, received number)
items: [[]] accept REFUSE @ items[0] (expected object, received array)
a feed-variant items array accept accept
an ordinary gantt row accept accept

Three corners where validate is now stricter than the renderer — [{ items: null }], [0], [[]] — are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a document validate admits, and is only ever more lenient than validate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.

(c) Fixture census — every in-repo type: 'timeline' document through the tightened mirror

12 documents, 0 new refusals (each accept on main and on head):

examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json
packages/types/examples/data-display-examples.json#examples.timeline
content/docs/plugins/plugin-timeline.mdx fences at lines 36, 168, 203, 246, 327, 342 (object literal extracted by brace-matching, evaluated)
content/docs/api/schema-reference.md inline documents at lines 668, 1093

Population: grep -rl over examples/, packages/*/catalog (none exist), content/docs, packages/*/examples and the schema-catalog tests for type: 'timeline' / "type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned in timeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.

Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed

Committed first; each leg mutated renderer.tsx with an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored with git checkout HEAD -- path proven by git hash-object equal to the HEAD blob (36abf53e…) and an empty git diff HEAD, trap-guarded, absolute paths. No dist is involved (the pin imports ../renderer; vitest aliases workspace packages to src), so no rebuild leg.

  • Leg 1 — drop the items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crashitems: {} / 'x' / 5 rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.
  • Leg 2 — drop the null-row refusal: 3 red / 19 green. Direction: relocation into the normalizerTypeError: Cannot read properties of null (reading 'items') at renderer.tsx:345:26 (classifyGanttRows), exactly the card's relocation pattern.
  • Leg 3 — drop the truthy-non-array row.items refusal: 7 red / 15 green. Direction: relocation into calculateDateRangeTypeError: row.items.flatMap is not a function at renderer.tsx:367:25, and the 'x' row falling back to the DATE copy.

Exotic-class pins that moved (stated, exercised)

Array.isArray in classifyGanttRows runs before U1 (items.length) and U4 (rowItems.length) can. A revoked Proxy dies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throw Cannot perform 'IsArray' on a proxy that has been revoked (pin 4's class) instead of 'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks on findUnusableGanttDate, calculateDateRange, spellGanttDateValue and the gantt branch updated to say what is true now.

Verification (final commit 8e1dc8274)

  • Union, run AFTER the final commit, head echoed by the run: pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/Test Files 168 passed (168) · Tests 2689 passed (2689); os-verify-lock: VERDICT command-exit 0.
  • Type-checks (dependency closure built first, pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." build exit 0): pnpm --filter @object-ui/plugin-timeline run type-check (echoed tsc --noEmit && tsc -p tsconfig.test.json) EXIT=0; @object-ui/types (echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0; @object-ui/i18n EXIT=0. --listFiles on the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.
  • ESLint (--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-pattern no-explicit-any / react-refresh classes; no new rule class).
  • Gates: check-changeset-presence ✅ (16 source files of 3 released packages, 1 changeset declared) · check-changeset-no-major ✅ · check-changeset-fixed ✅ · check:control-bytes ✅ (6056 files) · check:i18n-keys ✅ (every call-site key resolves; 2907 en keys) · check:i18n-drift ✅ (0 en values changed, 1 key added — parity's business, and all-locales-key-parity is green) · check:i18n-dead-keys report unchanged · check:vi-mock-specifiers ✅ · check:vi-mock-inherit ✅.
  • Governed-surface predicate (objectstack/scripts/pm/check-governed-merges.mjs --test on the final 19-path list): 0 of 19 path(s) hit the registerNOT governed.
  • check:eager-closure: NOT MEASURED (needs a full apps/console build; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; the framework chunk's headroom in scripts/check-eager-closure-budget.mjs is 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.

Deviations from the dispatch (each declared, none silent)

  1. The NAMED row's outcome changed.items: [{ items: 'x' }] was NAMED through malformedDate on main by accident (a string is index-readable). The ruling's door is "a row whose items is a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED through malformedRow naming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.
  2. Hole named {{value}}, not {{fault}}. The sibling key in the same block uses {{path}} / {{value}} and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.
  3. ZONE 2 item 2's "row null/non-object" narrowed to null/undefined. ZONE 1 6(a) pins items: [0] and [[]] as drawing CONTROLs, and the ruling names null rows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0], ['x'], [true], [[]]) draws an unlabelled empty row silently — while validate now refuses it #7364) rather than decided here.
  4. The plugin's provider-less default table got the same key (useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror of en for the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.
  5. A third row was added to revokedPositions typing in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.

Out of scope, filed

ObjectTimeline.tsx is read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho


Generated by Claude Code

… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3167.3 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-Eyz6MvMH.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.59KB117.40KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)208.92KB56.59KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

2 participants

@os-litant@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal
Sep 2, 2026
Merged

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror#7367
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#7164
Clause-②: yes — contract review required before release

Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the malformedDate copy; AND tighten the zod mirror so null rows and non-array items are refused by validate before a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

What changed

Render-time door (packages/plugin-timeline/src/renderer.tsx). One normalizer, classifyGanttRows(items), reads the raw shape ONCE and returns either { ok: true, rows } — a readonly GanttRow[] whose items is an array by construction — or { ok: false, path, value }. Three refusals, in walk order: items not an array → items; a null / undefined row → items[i]; a truthy non-array row.itemsitems[i].items. The three readers the card named — findUnusableGanttDate, calculateDateRange and the render loop — now all consume the verdict; none re-reads schema.items. The refusal renders the existing role="alert" surface (#6759's element, same data-testid) through the new key, with {{path}} and {{value}} (the value spelled by spellGanttDateValue, so no author code runs). onItemClick still receives the author's own row object (pinned).

The keytimeline.gantt.unusableRange.malformedRow, placed directly after malformedDate inside the existing unusableRange block in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack's malformedDate line; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, as malformedDate has). English:

Unusable gantt rows — {{path}} is {{value}}, which is not a row shape. A gantt draws items as a list of rows, every row as an object with a label and its own items, and every row's items as a list of bars; a null, a number, a string or a plain object in any of those places cannot be drawn.

Author-time door (packages/types/src/zod/data-display.zod.ts). TimelineSchema.items is now z.array(TimelineRowSchema) where the (non-exported, per the parity-registry convention) row schema is z.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row's items, when present, an array. Nothing else narrowed — feed items carry no items key and parse as before; bars stay z.any(). The TS twin (data-display.ts) keeps items?: any[] (its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[] is assignable to the narrowed input), so KnownDrift is untouched.

Pins. New timeline-gantt-malformed-row-7164.test.tsx (22 tests: the card's table, controls, order, onItemClick identity, the key); new timeline-items-row-shape-7164.test.ts in packages/types (accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin's pin 6 (which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of its pin 5 moved with the reads they exercise (below). The 6907 pin's "no key was added" it title is corrected to what it asserts.

Changeset.changeset/7164-gantt-malformed-row-refusal.md: @object-ui/plugin-timeline patch · @object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) · @object-ui/i18n patch.

Contract-review pack

(a) The card's table, re-run on 8e1dc8274 through the real TimelineRenderer

RED first, on a67abdc88 (same probe, before any edit):

items: [null] THREW renderer.tsx:287:10 (row.items)
items: [{ items: 5 }] THREW renderer.tsx:287:23 ((row.items || []).flatMap)
items: [{ items: {} }] THREW renderer.tsx:287:23
items: [{ items: true }] THREW renderer.tsx:287:23
items: [{ items: {length:1, 0:{dates}} }] THREW renderer.tsx:287:23
items: {} THREW renderer.tsx:286:26 (items.flatMap)
items: 'x' THREW renderer.tsx:286:26
items: 5 THREW renderer.tsx:286:26
items: [{ items: 'x' }] NAMED items[0].items[0].startDate is undefined, which is not a valid date
CONTROL an ordinary row DREW bars=1 axisCells=3
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

GREEN, on 8e1dc8274 (the new pin asserts each line; 22/22 pass):

items: [null] REFUSED malformedRow "items[0] is null, which is not a row shape"
items: [{ items: 5 }] REFUSED malformedRow "items[0].items is 5, …"
items: [{ items: {} }] REFUSED malformedRow "items[0].items is an object, …"
items: [{ items: true }] REFUSED malformedRow "items[0].items is true, …"
items: [{ items: {length:1, 0:{dates}} }] REFUSED malformedRow "items[0].items is an object, …"
items: {} REFUSED malformedRow "items is an object, …"
items: 'x' REFUSED malformedRow "items is \"x\", …"
items: 5 REFUSED malformedRow "items is 5, …"
items: [{ items: 'x' }] REFUSED malformedRow "items[0].items is \"x\", …" ⚠ CHANGED — see deviation 1
CONTROL an ordinary row DREW bars=1 axisCells=3 (axis Jan/Feb/Mar 2024, unchanged)
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT malformedDate's. Order pinned: a null row at items[1] wins over an unparseable date at items[2]; a well-formed row with a bad date still takes malformedDate unchanged.

(b) The mirror's accept set — safeParse, origin/main (a67abdc88) vs head (8e1dc8274)

Measured by a probe that rebuilt the old declaration (z.array(z.any()).optional()) on the same base:

input main head
items: [null] accept REFUSE @ items[0] (expected object, received null)
items: [{ items: 5 }] accept REFUSE @ items[0].items (expected array, received number)
items: [{ items: {} }] accept REFUSE @ items[0].items (expected array, received object)
items: [{ items: { length: 1, 0: {…} } }] accept REFUSE @ items[0].items (expected array, received object)
items: {} REFUSE REFUSE @ items
items: 'x' REFUSE REFUSE @ items
items: [{ items: 'x' }] accept REFUSE @ items[0].items (expected array, received string)
items: [] accept accept
items: [{ label: 'R' }] accept accept
items: [{ items: null }] accept REFUSE @ items[0].items (expected array, received null)
items: [0] accept REFUSE @ items[0] (expected object, received number)
items: [[]] accept REFUSE @ items[0] (expected object, received array)
a feed-variant items array accept accept
an ordinary gantt row accept accept

Three corners where validate is now stricter than the renderer — [{ items: null }], [0], [[]] — are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a document validate admits, and is only ever more lenient than validate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.

(c) Fixture census — every in-repo type: 'timeline' document through the tightened mirror

12 documents, 0 new refusals (each accept on main and on head):

examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json
packages/types/examples/data-display-examples.json#examples.timeline
content/docs/plugins/plugin-timeline.mdx fences at lines 36, 168, 203, 246, 327, 342 (object literal extracted by brace-matching, evaluated)
content/docs/api/schema-reference.md inline documents at lines 668, 1093

Population: grep -rl over examples/, packages/*/catalog (none exist), content/docs, packages/*/examples and the schema-catalog tests for type: 'timeline' / "type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned in timeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.

Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed

Committed first; each leg mutated renderer.tsx with an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored with git checkout HEAD -- path proven by git hash-object equal to the HEAD blob (36abf53e…) and an empty git diff HEAD, trap-guarded, absolute paths. No dist is involved (the pin imports ../renderer; vitest aliases workspace packages to src), so no rebuild leg.

  • Leg 1 — drop the items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crashitems: {} / 'x' / 5 rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.
  • Leg 2 — drop the null-row refusal: 3 red / 19 green. Direction: relocation into the normalizerTypeError: Cannot read properties of null (reading 'items') at renderer.tsx:345:26 (classifyGanttRows), exactly the card's relocation pattern.
  • Leg 3 — drop the truthy-non-array row.items refusal: 7 red / 15 green. Direction: relocation into calculateDateRangeTypeError: row.items.flatMap is not a function at renderer.tsx:367:25, and the 'x' row falling back to the DATE copy.

Exotic-class pins that moved (stated, exercised)

Array.isArray in classifyGanttRows runs before U1 (items.length) and U4 (rowItems.length) can. A revoked Proxy dies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throw Cannot perform 'IsArray' on a proxy that has been revoked (pin 4's class) instead of 'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks on findUnusableGanttDate, calculateDateRange, spellGanttDateValue and the gantt branch updated to say what is true now.

Verification (final commit 8e1dc8274)

  • Union, run AFTER the final commit, head echoed by the run: pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/Test Files 168 passed (168) · Tests 2689 passed (2689); os-verify-lock: VERDICT command-exit 0.
  • Type-checks (dependency closure built first, pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." build exit 0): pnpm --filter @object-ui/plugin-timeline run type-check (echoed tsc --noEmit && tsc -p tsconfig.test.json) EXIT=0; @object-ui/types (echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0; @object-ui/i18n EXIT=0. --listFiles on the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.
  • ESLint (--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-pattern no-explicit-any / react-refresh classes; no new rule class).
  • Gates: check-changeset-presence ✅ (16 source files of 3 released packages, 1 changeset declared) · check-changeset-no-major ✅ · check-changeset-fixed ✅ · check:control-bytes ✅ (6056 files) · check:i18n-keys ✅ (every call-site key resolves; 2907 en keys) · check:i18n-drift ✅ (0 en values changed, 1 key added — parity's business, and all-locales-key-parity is green) · check:i18n-dead-keys report unchanged · check:vi-mock-specifiers ✅ · check:vi-mock-inherit ✅.
  • Governed-surface predicate (objectstack/scripts/pm/check-governed-merges.mjs --test on the final 19-path list): 0 of 19 path(s) hit the registerNOT governed.
  • check:eager-closure: NOT MEASURED (needs a full apps/console build; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; the framework chunk's headroom in scripts/check-eager-closure-budget.mjs is 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.

Deviations from the dispatch (each declared, none silent)

  1. The NAMED row's outcome changed.items: [{ items: 'x' }] was NAMED through malformedDate on main by accident (a string is index-readable). The ruling's door is "a row whose items is a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED through malformedRow naming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.
  2. Hole named {{value}}, not {{fault}}. The sibling key in the same block uses {{path}} / {{value}} and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.
  3. ZONE 2 item 2's "row null/non-object" narrowed to null/undefined. ZONE 1 6(a) pins items: [0] and [[]] as drawing CONTROLs, and the ruling names null rows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0], ['x'], [true], [[]]) draws an unlabelled empty row silently — while validate now refuses it #7364) rather than decided here.
  4. The plugin's provider-less default table got the same key (useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror of en for the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.
  5. A third row was added to revokedPositions typing in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.

Out of scope, filed

ObjectTimeline.tsx is read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho


Generated by Claude Code

… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3167.3 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-Eyz6MvMH.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.59KB117.40KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)208.92KB56.59KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

2 participants

@os-litant@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal
Sep 2, 2026
Merged

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror#7367
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#7164
Clause-②: yes — contract review required before release

Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the malformedDate copy; AND tighten the zod mirror so null rows and non-array items are refused by validate before a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

What changed

Render-time door (packages/plugin-timeline/src/renderer.tsx). One normalizer, classifyGanttRows(items), reads the raw shape ONCE and returns either { ok: true, rows } — a readonly GanttRow[] whose items is an array by construction — or { ok: false, path, value }. Three refusals, in walk order: items not an array → items; a null / undefined row → items[i]; a truthy non-array row.itemsitems[i].items. The three readers the card named — findUnusableGanttDate, calculateDateRange and the render loop — now all consume the verdict; none re-reads schema.items. The refusal renders the existing role="alert" surface (#6759's element, same data-testid) through the new key, with {{path}} and {{value}} (the value spelled by spellGanttDateValue, so no author code runs). onItemClick still receives the author's own row object (pinned).

The keytimeline.gantt.unusableRange.malformedRow, placed directly after malformedDate inside the existing unusableRange block in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack's malformedDate line; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, as malformedDate has). English:

Unusable gantt rows — {{path}} is {{value}}, which is not a row shape. A gantt draws items as a list of rows, every row as an object with a label and its own items, and every row's items as a list of bars; a null, a number, a string or a plain object in any of those places cannot be drawn.

Author-time door (packages/types/src/zod/data-display.zod.ts). TimelineSchema.items is now z.array(TimelineRowSchema) where the (non-exported, per the parity-registry convention) row schema is z.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row's items, when present, an array. Nothing else narrowed — feed items carry no items key and parse as before; bars stay z.any(). The TS twin (data-display.ts) keeps items?: any[] (its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[] is assignable to the narrowed input), so KnownDrift is untouched.

Pins. New timeline-gantt-malformed-row-7164.test.tsx (22 tests: the card's table, controls, order, onItemClick identity, the key); new timeline-items-row-shape-7164.test.ts in packages/types (accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin's pin 6 (which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of its pin 5 moved with the reads they exercise (below). The 6907 pin's "no key was added" it title is corrected to what it asserts.

Changeset.changeset/7164-gantt-malformed-row-refusal.md: @object-ui/plugin-timeline patch · @object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) · @object-ui/i18n patch.

Contract-review pack

(a) The card's table, re-run on 8e1dc8274 through the real TimelineRenderer

RED first, on a67abdc88 (same probe, before any edit):

items: [null] THREW renderer.tsx:287:10 (row.items)
items: [{ items: 5 }] THREW renderer.tsx:287:23 ((row.items || []).flatMap)
items: [{ items: {} }] THREW renderer.tsx:287:23
items: [{ items: true }] THREW renderer.tsx:287:23
items: [{ items: {length:1, 0:{dates}} }] THREW renderer.tsx:287:23
items: {} THREW renderer.tsx:286:26 (items.flatMap)
items: 'x' THREW renderer.tsx:286:26
items: 5 THREW renderer.tsx:286:26
items: [{ items: 'x' }] NAMED items[0].items[0].startDate is undefined, which is not a valid date
CONTROL an ordinary row DREW bars=1 axisCells=3
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

GREEN, on 8e1dc8274 (the new pin asserts each line; 22/22 pass):

items: [null] REFUSED malformedRow "items[0] is null, which is not a row shape"
items: [{ items: 5 }] REFUSED malformedRow "items[0].items is 5, …"
items: [{ items: {} }] REFUSED malformedRow "items[0].items is an object, …"
items: [{ items: true }] REFUSED malformedRow "items[0].items is true, …"
items: [{ items: {length:1, 0:{dates}} }] REFUSED malformedRow "items[0].items is an object, …"
items: {} REFUSED malformedRow "items is an object, …"
items: 'x' REFUSED malformedRow "items is \"x\", …"
items: 5 REFUSED malformedRow "items is 5, …"
items: [{ items: 'x' }] REFUSED malformedRow "items[0].items is \"x\", …" ⚠ CHANGED — see deviation 1
CONTROL an ordinary row DREW bars=1 axisCells=3 (axis Jan/Feb/Mar 2024, unchanged)
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT malformedDate's. Order pinned: a null row at items[1] wins over an unparseable date at items[2]; a well-formed row with a bad date still takes malformedDate unchanged.

(b) The mirror's accept set — safeParse, origin/main (a67abdc88) vs head (8e1dc8274)

Measured by a probe that rebuilt the old declaration (z.array(z.any()).optional()) on the same base:

input main head
items: [null] accept REFUSE @ items[0] (expected object, received null)
items: [{ items: 5 }] accept REFUSE @ items[0].items (expected array, received number)
items: [{ items: {} }] accept REFUSE @ items[0].items (expected array, received object)
items: [{ items: { length: 1, 0: {…} } }] accept REFUSE @ items[0].items (expected array, received object)
items: {} REFUSE REFUSE @ items
items: 'x' REFUSE REFUSE @ items
items: [{ items: 'x' }] accept REFUSE @ items[0].items (expected array, received string)
items: [] accept accept
items: [{ label: 'R' }] accept accept
items: [{ items: null }] accept REFUSE @ items[0].items (expected array, received null)
items: [0] accept REFUSE @ items[0] (expected object, received number)
items: [[]] accept REFUSE @ items[0] (expected object, received array)
a feed-variant items array accept accept
an ordinary gantt row accept accept

Three corners where validate is now stricter than the renderer — [{ items: null }], [0], [[]] — are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a document validate admits, and is only ever more lenient than validate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.

(c) Fixture census — every in-repo type: 'timeline' document through the tightened mirror

12 documents, 0 new refusals (each accept on main and on head):

examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json
packages/types/examples/data-display-examples.json#examples.timeline
content/docs/plugins/plugin-timeline.mdx fences at lines 36, 168, 203, 246, 327, 342 (object literal extracted by brace-matching, evaluated)
content/docs/api/schema-reference.md inline documents at lines 668, 1093

Population: grep -rl over examples/, packages/*/catalog (none exist), content/docs, packages/*/examples and the schema-catalog tests for type: 'timeline' / "type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned in timeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.

Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed

Committed first; each leg mutated renderer.tsx with an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored with git checkout HEAD -- path proven by git hash-object equal to the HEAD blob (36abf53e…) and an empty git diff HEAD, trap-guarded, absolute paths. No dist is involved (the pin imports ../renderer; vitest aliases workspace packages to src), so no rebuild leg.

  • Leg 1 — drop the items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crashitems: {} / 'x' / 5 rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.
  • Leg 2 — drop the null-row refusal: 3 red / 19 green. Direction: relocation into the normalizerTypeError: Cannot read properties of null (reading 'items') at renderer.tsx:345:26 (classifyGanttRows), exactly the card's relocation pattern.
  • Leg 3 — drop the truthy-non-array row.items refusal: 7 red / 15 green. Direction: relocation into calculateDateRangeTypeError: row.items.flatMap is not a function at renderer.tsx:367:25, and the 'x' row falling back to the DATE copy.

Exotic-class pins that moved (stated, exercised)

Array.isArray in classifyGanttRows runs before U1 (items.length) and U4 (rowItems.length) can. A revoked Proxy dies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throw Cannot perform 'IsArray' on a proxy that has been revoked (pin 4's class) instead of 'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks on findUnusableGanttDate, calculateDateRange, spellGanttDateValue and the gantt branch updated to say what is true now.

Verification (final commit 8e1dc8274)

  • Union, run AFTER the final commit, head echoed by the run: pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/Test Files 168 passed (168) · Tests 2689 passed (2689); os-verify-lock: VERDICT command-exit 0.
  • Type-checks (dependency closure built first, pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." build exit 0): pnpm --filter @object-ui/plugin-timeline run type-check (echoed tsc --noEmit && tsc -p tsconfig.test.json) EXIT=0; @object-ui/types (echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0; @object-ui/i18n EXIT=0. --listFiles on the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.
  • ESLint (--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-pattern no-explicit-any / react-refresh classes; no new rule class).
  • Gates: check-changeset-presence ✅ (16 source files of 3 released packages, 1 changeset declared) · check-changeset-no-major ✅ · check-changeset-fixed ✅ · check:control-bytes ✅ (6056 files) · check:i18n-keys ✅ (every call-site key resolves; 2907 en keys) · check:i18n-drift ✅ (0 en values changed, 1 key added — parity's business, and all-locales-key-parity is green) · check:i18n-dead-keys report unchanged · check:vi-mock-specifiers ✅ · check:vi-mock-inherit ✅.
  • Governed-surface predicate (objectstack/scripts/pm/check-governed-merges.mjs --test on the final 19-path list): 0 of 19 path(s) hit the registerNOT governed.
  • check:eager-closure: NOT MEASURED (needs a full apps/console build; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; the framework chunk's headroom in scripts/check-eager-closure-budget.mjs is 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.

Deviations from the dispatch (each declared, none silent)

  1. The NAMED row's outcome changed.items: [{ items: 'x' }] was NAMED through malformedDate on main by accident (a string is index-readable). The ruling's door is "a row whose items is a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED through malformedRow naming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.
  2. Hole named {{value}}, not {{fault}}. The sibling key in the same block uses {{path}} / {{value}} and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.
  3. ZONE 2 item 2's "row null/non-object" narrowed to null/undefined. ZONE 1 6(a) pins items: [0] and [[]] as drawing CONTROLs, and the ruling names null rows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0], ['x'], [true], [[]]) draws an unlabelled empty row silently — while validate now refuses it #7364) rather than decided here.
  4. The plugin's provider-less default table got the same key (useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror of en for the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.
  5. A third row was added to revokedPositions typing in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.

Out of scope, filed

ObjectTimeline.tsx is read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho


Generated by Claude Code

… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3167.3 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-Eyz6MvMH.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.59KB117.40KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)208.92KB56.59KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

2 participants

@os-litant@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal
Sep 2, 2026
Merged

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror#7367
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#7164
Clause-②: yes — contract review required before release

Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the malformedDate copy; AND tighten the zod mirror so null rows and non-array items are refused by validate before a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

What changed

Render-time door (packages/plugin-timeline/src/renderer.tsx). One normalizer, classifyGanttRows(items), reads the raw shape ONCE and returns either { ok: true, rows } — a readonly GanttRow[] whose items is an array by construction — or { ok: false, path, value }. Three refusals, in walk order: items not an array → items; a null / undefined row → items[i]; a truthy non-array row.itemsitems[i].items. The three readers the card named — findUnusableGanttDate, calculateDateRange and the render loop — now all consume the verdict; none re-reads schema.items. The refusal renders the existing role="alert" surface (#6759's element, same data-testid) through the new key, with {{path}} and {{value}} (the value spelled by spellGanttDateValue, so no author code runs). onItemClick still receives the author's own row object (pinned).

The keytimeline.gantt.unusableRange.malformedRow, placed directly after malformedDate inside the existing unusableRange block in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack's malformedDate line; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, as malformedDate has). English:

Unusable gantt rows — {{path}} is {{value}}, which is not a row shape. A gantt draws items as a list of rows, every row as an object with a label and its own items, and every row's items as a list of bars; a null, a number, a string or a plain object in any of those places cannot be drawn.

Author-time door (packages/types/src/zod/data-display.zod.ts). TimelineSchema.items is now z.array(TimelineRowSchema) where the (non-exported, per the parity-registry convention) row schema is z.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row's items, when present, an array. Nothing else narrowed — feed items carry no items key and parse as before; bars stay z.any(). The TS twin (data-display.ts) keeps items?: any[] (its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[] is assignable to the narrowed input), so KnownDrift is untouched.

Pins. New timeline-gantt-malformed-row-7164.test.tsx (22 tests: the card's table, controls, order, onItemClick identity, the key); new timeline-items-row-shape-7164.test.ts in packages/types (accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin's pin 6 (which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of its pin 5 moved with the reads they exercise (below). The 6907 pin's "no key was added" it title is corrected to what it asserts.

Changeset.changeset/7164-gantt-malformed-row-refusal.md: @object-ui/plugin-timeline patch · @object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) · @object-ui/i18n patch.

Contract-review pack

(a) The card's table, re-run on 8e1dc8274 through the real TimelineRenderer

RED first, on a67abdc88 (same probe, before any edit):

items: [null] THREW renderer.tsx:287:10 (row.items)
items: [{ items: 5 }] THREW renderer.tsx:287:23 ((row.items || []).flatMap)
items: [{ items: {} }] THREW renderer.tsx:287:23
items: [{ items: true }] THREW renderer.tsx:287:23
items: [{ items: {length:1, 0:{dates}} }] THREW renderer.tsx:287:23
items: {} THREW renderer.tsx:286:26 (items.flatMap)
items: 'x' THREW renderer.tsx:286:26
items: 5 THREW renderer.tsx:286:26
items: [{ items: 'x' }] NAMED items[0].items[0].startDate is undefined, which is not a valid date
CONTROL an ordinary row DREW bars=1 axisCells=3
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

GREEN, on 8e1dc8274 (the new pin asserts each line; 22/22 pass):

items: [null] REFUSED malformedRow "items[0] is null, which is not a row shape"
items: [{ items: 5 }] REFUSED malformedRow "items[0].items is 5, …"
items: [{ items: {} }] REFUSED malformedRow "items[0].items is an object, …"
items: [{ items: true }] REFUSED malformedRow "items[0].items is true, …"
items: [{ items: {length:1, 0:{dates}} }] REFUSED malformedRow "items[0].items is an object, …"
items: {} REFUSED malformedRow "items is an object, …"
items: 'x' REFUSED malformedRow "items is \"x\", …"
items: 5 REFUSED malformedRow "items is 5, …"
items: [{ items: 'x' }] REFUSED malformedRow "items[0].items is \"x\", …" ⚠ CHANGED — see deviation 1
CONTROL an ordinary row DREW bars=1 axisCells=3 (axis Jan/Feb/Mar 2024, unchanged)
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT malformedDate's. Order pinned: a null row at items[1] wins over an unparseable date at items[2]; a well-formed row with a bad date still takes malformedDate unchanged.

(b) The mirror's accept set — safeParse, origin/main (a67abdc88) vs head (8e1dc8274)

Measured by a probe that rebuilt the old declaration (z.array(z.any()).optional()) on the same base:

input main head
items: [null] accept REFUSE @ items[0] (expected object, received null)
items: [{ items: 5 }] accept REFUSE @ items[0].items (expected array, received number)
items: [{ items: {} }] accept REFUSE @ items[0].items (expected array, received object)
items: [{ items: { length: 1, 0: {…} } }] accept REFUSE @ items[0].items (expected array, received object)
items: {} REFUSE REFUSE @ items
items: 'x' REFUSE REFUSE @ items
items: [{ items: 'x' }] accept REFUSE @ items[0].items (expected array, received string)
items: [] accept accept
items: [{ label: 'R' }] accept accept
items: [{ items: null }] accept REFUSE @ items[0].items (expected array, received null)
items: [0] accept REFUSE @ items[0] (expected object, received number)
items: [[]] accept REFUSE @ items[0] (expected object, received array)
a feed-variant items array accept accept
an ordinary gantt row accept accept

Three corners where validate is now stricter than the renderer — [{ items: null }], [0], [[]] — are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a document validate admits, and is only ever more lenient than validate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.

(c) Fixture census — every in-repo type: 'timeline' document through the tightened mirror

12 documents, 0 new refusals (each accept on main and on head):

examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json
packages/types/examples/data-display-examples.json#examples.timeline
content/docs/plugins/plugin-timeline.mdx fences at lines 36, 168, 203, 246, 327, 342 (object literal extracted by brace-matching, evaluated)
content/docs/api/schema-reference.md inline documents at lines 668, 1093

Population: grep -rl over examples/, packages/*/catalog (none exist), content/docs, packages/*/examples and the schema-catalog tests for type: 'timeline' / "type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned in timeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.

Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed

Committed first; each leg mutated renderer.tsx with an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored with git checkout HEAD -- path proven by git hash-object equal to the HEAD blob (36abf53e…) and an empty git diff HEAD, trap-guarded, absolute paths. No dist is involved (the pin imports ../renderer; vitest aliases workspace packages to src), so no rebuild leg.

  • Leg 1 — drop the items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crashitems: {} / 'x' / 5 rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.
  • Leg 2 — drop the null-row refusal: 3 red / 19 green. Direction: relocation into the normalizerTypeError: Cannot read properties of null (reading 'items') at renderer.tsx:345:26 (classifyGanttRows), exactly the card's relocation pattern.
  • Leg 3 — drop the truthy-non-array row.items refusal: 7 red / 15 green. Direction: relocation into calculateDateRangeTypeError: row.items.flatMap is not a function at renderer.tsx:367:25, and the 'x' row falling back to the DATE copy.

Exotic-class pins that moved (stated, exercised)

Array.isArray in classifyGanttRows runs before U1 (items.length) and U4 (rowItems.length) can. A revoked Proxy dies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throw Cannot perform 'IsArray' on a proxy that has been revoked (pin 4's class) instead of 'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks on findUnusableGanttDate, calculateDateRange, spellGanttDateValue and the gantt branch updated to say what is true now.

Verification (final commit 8e1dc8274)

  • Union, run AFTER the final commit, head echoed by the run: pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/Test Files 168 passed (168) · Tests 2689 passed (2689); os-verify-lock: VERDICT command-exit 0.
  • Type-checks (dependency closure built first, pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." build exit 0): pnpm --filter @object-ui/plugin-timeline run type-check (echoed tsc --noEmit && tsc -p tsconfig.test.json) EXIT=0; @object-ui/types (echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0; @object-ui/i18n EXIT=0. --listFiles on the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.
  • ESLint (--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-pattern no-explicit-any / react-refresh classes; no new rule class).
  • Gates: check-changeset-presence ✅ (16 source files of 3 released packages, 1 changeset declared) · check-changeset-no-major ✅ · check-changeset-fixed ✅ · check:control-bytes ✅ (6056 files) · check:i18n-keys ✅ (every call-site key resolves; 2907 en keys) · check:i18n-drift ✅ (0 en values changed, 1 key added — parity's business, and all-locales-key-parity is green) · check:i18n-dead-keys report unchanged · check:vi-mock-specifiers ✅ · check:vi-mock-inherit ✅.
  • Governed-surface predicate (objectstack/scripts/pm/check-governed-merges.mjs --test on the final 19-path list): 0 of 19 path(s) hit the registerNOT governed.
  • check:eager-closure: NOT MEASURED (needs a full apps/console build; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; the framework chunk's headroom in scripts/check-eager-closure-budget.mjs is 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.

Deviations from the dispatch (each declared, none silent)

  1. The NAMED row's outcome changed.items: [{ items: 'x' }] was NAMED through malformedDate on main by accident (a string is index-readable). The ruling's door is "a row whose items is a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED through malformedRow naming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.
  2. Hole named {{value}}, not {{fault}}. The sibling key in the same block uses {{path}} / {{value}} and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.
  3. ZONE 2 item 2's "row null/non-object" narrowed to null/undefined. ZONE 1 6(a) pins items: [0] and [[]] as drawing CONTROLs, and the ruling names null rows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0], ['x'], [true], [[]]) draws an unlabelled empty row silently — while validate now refuses it #7364) rather than decided here.
  4. The plugin's provider-less default table got the same key (useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror of en for the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.
  5. A third row was added to revokedPositions typing in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.

Out of scope, filed

ObjectTimeline.tsx is read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho


Generated by Claude Code

… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3167.3 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-Eyz6MvMH.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.59KB117.40KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)208.92KB56.59KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

2 participants

@os-litant@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal
Sep 2, 2026
Merged

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror#7367
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#7164
Clause-②: yes — contract review required before release

Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the malformedDate copy; AND tighten the zod mirror so null rows and non-array items are refused by validate before a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

What changed

Render-time door (packages/plugin-timeline/src/renderer.tsx). One normalizer, classifyGanttRows(items), reads the raw shape ONCE and returns either { ok: true, rows } — a readonly GanttRow[] whose items is an array by construction — or { ok: false, path, value }. Three refusals, in walk order: items not an array → items; a null / undefined row → items[i]; a truthy non-array row.itemsitems[i].items. The three readers the card named — findUnusableGanttDate, calculateDateRange and the render loop — now all consume the verdict; none re-reads schema.items. The refusal renders the existing role="alert" surface (#6759's element, same data-testid) through the new key, with {{path}} and {{value}} (the value spelled by spellGanttDateValue, so no author code runs). onItemClick still receives the author's own row object (pinned).

The keytimeline.gantt.unusableRange.malformedRow, placed directly after malformedDate inside the existing unusableRange block in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack's malformedDate line; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, as malformedDate has). English:

Unusable gantt rows — {{path}} is {{value}}, which is not a row shape. A gantt draws items as a list of rows, every row as an object with a label and its own items, and every row's items as a list of bars; a null, a number, a string or a plain object in any of those places cannot be drawn.

Author-time door (packages/types/src/zod/data-display.zod.ts). TimelineSchema.items is now z.array(TimelineRowSchema) where the (non-exported, per the parity-registry convention) row schema is z.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row's items, when present, an array. Nothing else narrowed — feed items carry no items key and parse as before; bars stay z.any(). The TS twin (data-display.ts) keeps items?: any[] (its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[] is assignable to the narrowed input), so KnownDrift is untouched.

Pins. New timeline-gantt-malformed-row-7164.test.tsx (22 tests: the card's table, controls, order, onItemClick identity, the key); new timeline-items-row-shape-7164.test.ts in packages/types (accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin's pin 6 (which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of its pin 5 moved with the reads they exercise (below). The 6907 pin's "no key was added" it title is corrected to what it asserts.

Changeset.changeset/7164-gantt-malformed-row-refusal.md: @object-ui/plugin-timeline patch · @object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) · @object-ui/i18n patch.

Contract-review pack

(a) The card's table, re-run on 8e1dc8274 through the real TimelineRenderer

RED first, on a67abdc88 (same probe, before any edit):

items: [null] THREW renderer.tsx:287:10 (row.items)
items: [{ items: 5 }] THREW renderer.tsx:287:23 ((row.items || []).flatMap)
items: [{ items: {} }] THREW renderer.tsx:287:23
items: [{ items: true }] THREW renderer.tsx:287:23
items: [{ items: {length:1, 0:{dates}} }] THREW renderer.tsx:287:23
items: {} THREW renderer.tsx:286:26 (items.flatMap)
items: 'x' THREW renderer.tsx:286:26
items: 5 THREW renderer.tsx:286:26
items: [{ items: 'x' }] NAMED items[0].items[0].startDate is undefined, which is not a valid date
CONTROL an ordinary row DREW bars=1 axisCells=3
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

GREEN, on 8e1dc8274 (the new pin asserts each line; 22/22 pass):

items: [null] REFUSED malformedRow "items[0] is null, which is not a row shape"
items: [{ items: 5 }] REFUSED malformedRow "items[0].items is 5, …"
items: [{ items: {} }] REFUSED malformedRow "items[0].items is an object, …"
items: [{ items: true }] REFUSED malformedRow "items[0].items is true, …"
items: [{ items: {length:1, 0:{dates}} }] REFUSED malformedRow "items[0].items is an object, …"
items: {} REFUSED malformedRow "items is an object, …"
items: 'x' REFUSED malformedRow "items is \"x\", …"
items: 5 REFUSED malformedRow "items is 5, …"
items: [{ items: 'x' }] REFUSED malformedRow "items[0].items is \"x\", …" ⚠ CHANGED — see deviation 1
CONTROL an ordinary row DREW bars=1 axisCells=3 (axis Jan/Feb/Mar 2024, unchanged)
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT malformedDate's. Order pinned: a null row at items[1] wins over an unparseable date at items[2]; a well-formed row with a bad date still takes malformedDate unchanged.

(b) The mirror's accept set — safeParse, origin/main (a67abdc88) vs head (8e1dc8274)

Measured by a probe that rebuilt the old declaration (z.array(z.any()).optional()) on the same base:

input main head
items: [null] accept REFUSE @ items[0] (expected object, received null)
items: [{ items: 5 }] accept REFUSE @ items[0].items (expected array, received number)
items: [{ items: {} }] accept REFUSE @ items[0].items (expected array, received object)
items: [{ items: { length: 1, 0: {…} } }] accept REFUSE @ items[0].items (expected array, received object)
items: {} REFUSE REFUSE @ items
items: 'x' REFUSE REFUSE @ items
items: [{ items: 'x' }] accept REFUSE @ items[0].items (expected array, received string)
items: [] accept accept
items: [{ label: 'R' }] accept accept
items: [{ items: null }] accept REFUSE @ items[0].items (expected array, received null)
items: [0] accept REFUSE @ items[0] (expected object, received number)
items: [[]] accept REFUSE @ items[0] (expected object, received array)
a feed-variant items array accept accept
an ordinary gantt row accept accept

Three corners where validate is now stricter than the renderer — [{ items: null }], [0], [[]] — are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a document validate admits, and is only ever more lenient than validate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.

(c) Fixture census — every in-repo type: 'timeline' document through the tightened mirror

12 documents, 0 new refusals (each accept on main and on head):

examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json
packages/types/examples/data-display-examples.json#examples.timeline
content/docs/plugins/plugin-timeline.mdx fences at lines 36, 168, 203, 246, 327, 342 (object literal extracted by brace-matching, evaluated)
content/docs/api/schema-reference.md inline documents at lines 668, 1093

Population: grep -rl over examples/, packages/*/catalog (none exist), content/docs, packages/*/examples and the schema-catalog tests for type: 'timeline' / "type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned in timeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.

Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed

Committed first; each leg mutated renderer.tsx with an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored with git checkout HEAD -- path proven by git hash-object equal to the HEAD blob (36abf53e…) and an empty git diff HEAD, trap-guarded, absolute paths. No dist is involved (the pin imports ../renderer; vitest aliases workspace packages to src), so no rebuild leg.

  • Leg 1 — drop the items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crashitems: {} / 'x' / 5 rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.
  • Leg 2 — drop the null-row refusal: 3 red / 19 green. Direction: relocation into the normalizerTypeError: Cannot read properties of null (reading 'items') at renderer.tsx:345:26 (classifyGanttRows), exactly the card's relocation pattern.
  • Leg 3 — drop the truthy-non-array row.items refusal: 7 red / 15 green. Direction: relocation into calculateDateRangeTypeError: row.items.flatMap is not a function at renderer.tsx:367:25, and the 'x' row falling back to the DATE copy.

Exotic-class pins that moved (stated, exercised)

Array.isArray in classifyGanttRows runs before U1 (items.length) and U4 (rowItems.length) can. A revoked Proxy dies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throw Cannot perform 'IsArray' on a proxy that has been revoked (pin 4's class) instead of 'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks on findUnusableGanttDate, calculateDateRange, spellGanttDateValue and the gantt branch updated to say what is true now.

Verification (final commit 8e1dc8274)

  • Union, run AFTER the final commit, head echoed by the run: pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/Test Files 168 passed (168) · Tests 2689 passed (2689); os-verify-lock: VERDICT command-exit 0.
  • Type-checks (dependency closure built first, pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." build exit 0): pnpm --filter @object-ui/plugin-timeline run type-check (echoed tsc --noEmit && tsc -p tsconfig.test.json) EXIT=0; @object-ui/types (echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0; @object-ui/i18n EXIT=0. --listFiles on the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.
  • ESLint (--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-pattern no-explicit-any / react-refresh classes; no new rule class).
  • Gates: check-changeset-presence ✅ (16 source files of 3 released packages, 1 changeset declared) · check-changeset-no-major ✅ · check-changeset-fixed ✅ · check:control-bytes ✅ (6056 files) · check:i18n-keys ✅ (every call-site key resolves; 2907 en keys) · check:i18n-drift ✅ (0 en values changed, 1 key added — parity's business, and all-locales-key-parity is green) · check:i18n-dead-keys report unchanged · check:vi-mock-specifiers ✅ · check:vi-mock-inherit ✅.
  • Governed-surface predicate (objectstack/scripts/pm/check-governed-merges.mjs --test on the final 19-path list): 0 of 19 path(s) hit the registerNOT governed.
  • check:eager-closure: NOT MEASURED (needs a full apps/console build; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; the framework chunk's headroom in scripts/check-eager-closure-budget.mjs is 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.

Deviations from the dispatch (each declared, none silent)

  1. The NAMED row's outcome changed.items: [{ items: 'x' }] was NAMED through malformedDate on main by accident (a string is index-readable). The ruling's door is "a row whose items is a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED through malformedRow naming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.
  2. Hole named {{value}}, not {{fault}}. The sibling key in the same block uses {{path}} / {{value}} and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.
  3. ZONE 2 item 2's "row null/non-object" narrowed to null/undefined. ZONE 1 6(a) pins items: [0] and [[]] as drawing CONTROLs, and the ruling names null rows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0], ['x'], [true], [[]]) draws an unlabelled empty row silently — while validate now refuses it #7364) rather than decided here.
  4. The plugin's provider-less default table got the same key (useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror of en for the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.
  5. A third row was added to revokedPositions typing in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.

Out of scope, filed

ObjectTimeline.tsx is read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho


Generated by Claude Code

… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3167.3 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-Eyz6MvMH.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.59KB117.40KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)208.92KB56.59KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

2 participants

@os-litant@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal
Sep 2, 2026
Merged

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror#7367
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#7164
Clause-②: yes — contract review required before release

Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the malformedDate copy; AND tighten the zod mirror so null rows and non-array items are refused by validate before a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

What changed

Render-time door (packages/plugin-timeline/src/renderer.tsx). One normalizer, classifyGanttRows(items), reads the raw shape ONCE and returns either { ok: true, rows } — a readonly GanttRow[] whose items is an array by construction — or { ok: false, path, value }. Three refusals, in walk order: items not an array → items; a null / undefined row → items[i]; a truthy non-array row.itemsitems[i].items. The three readers the card named — findUnusableGanttDate, calculateDateRange and the render loop — now all consume the verdict; none re-reads schema.items. The refusal renders the existing role="alert" surface (#6759's element, same data-testid) through the new key, with {{path}} and {{value}} (the value spelled by spellGanttDateValue, so no author code runs). onItemClick still receives the author's own row object (pinned).

The keytimeline.gantt.unusableRange.malformedRow, placed directly after malformedDate inside the existing unusableRange block in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack's malformedDate line; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, as malformedDate has). English:

Unusable gantt rows — {{path}} is {{value}}, which is not a row shape. A gantt draws items as a list of rows, every row as an object with a label and its own items, and every row's items as a list of bars; a null, a number, a string or a plain object in any of those places cannot be drawn.

Author-time door (packages/types/src/zod/data-display.zod.ts). TimelineSchema.items is now z.array(TimelineRowSchema) where the (non-exported, per the parity-registry convention) row schema is z.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row's items, when present, an array. Nothing else narrowed — feed items carry no items key and parse as before; bars stay z.any(). The TS twin (data-display.ts) keeps items?: any[] (its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[] is assignable to the narrowed input), so KnownDrift is untouched.

Pins. New timeline-gantt-malformed-row-7164.test.tsx (22 tests: the card's table, controls, order, onItemClick identity, the key); new timeline-items-row-shape-7164.test.ts in packages/types (accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin's pin 6 (which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of its pin 5 moved with the reads they exercise (below). The 6907 pin's "no key was added" it title is corrected to what it asserts.

Changeset.changeset/7164-gantt-malformed-row-refusal.md: @object-ui/plugin-timeline patch · @object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) · @object-ui/i18n patch.

Contract-review pack

(a) The card's table, re-run on 8e1dc8274 through the real TimelineRenderer

RED first, on a67abdc88 (same probe, before any edit):

items: [null] THREW renderer.tsx:287:10 (row.items)
items: [{ items: 5 }] THREW renderer.tsx:287:23 ((row.items || []).flatMap)
items: [{ items: {} }] THREW renderer.tsx:287:23
items: [{ items: true }] THREW renderer.tsx:287:23
items: [{ items: {length:1, 0:{dates}} }] THREW renderer.tsx:287:23
items: {} THREW renderer.tsx:286:26 (items.flatMap)
items: 'x' THREW renderer.tsx:286:26
items: 5 THREW renderer.tsx:286:26
items: [{ items: 'x' }] NAMED items[0].items[0].startDate is undefined, which is not a valid date
CONTROL an ordinary row DREW bars=1 axisCells=3
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

GREEN, on 8e1dc8274 (the new pin asserts each line; 22/22 pass):

items: [null] REFUSED malformedRow "items[0] is null, which is not a row shape"
items: [{ items: 5 }] REFUSED malformedRow "items[0].items is 5, …"
items: [{ items: {} }] REFUSED malformedRow "items[0].items is an object, …"
items: [{ items: true }] REFUSED malformedRow "items[0].items is true, …"
items: [{ items: {length:1, 0:{dates}} }] REFUSED malformedRow "items[0].items is an object, …"
items: {} REFUSED malformedRow "items is an object, …"
items: 'x' REFUSED malformedRow "items is \"x\", …"
items: 5 REFUSED malformedRow "items is 5, …"
items: [{ items: 'x' }] REFUSED malformedRow "items[0].items is \"x\", …" ⚠ CHANGED — see deviation 1
CONTROL an ordinary row DREW bars=1 axisCells=3 (axis Jan/Feb/Mar 2024, unchanged)
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT malformedDate's. Order pinned: a null row at items[1] wins over an unparseable date at items[2]; a well-formed row with a bad date still takes malformedDate unchanged.

(b) The mirror's accept set — safeParse, origin/main (a67abdc88) vs head (8e1dc8274)

Measured by a probe that rebuilt the old declaration (z.array(z.any()).optional()) on the same base:

input main head
items: [null] accept REFUSE @ items[0] (expected object, received null)
items: [{ items: 5 }] accept REFUSE @ items[0].items (expected array, received number)
items: [{ items: {} }] accept REFUSE @ items[0].items (expected array, received object)
items: [{ items: { length: 1, 0: {…} } }] accept REFUSE @ items[0].items (expected array, received object)
items: {} REFUSE REFUSE @ items
items: 'x' REFUSE REFUSE @ items
items: [{ items: 'x' }] accept REFUSE @ items[0].items (expected array, received string)
items: [] accept accept
items: [{ label: 'R' }] accept accept
items: [{ items: null }] accept REFUSE @ items[0].items (expected array, received null)
items: [0] accept REFUSE @ items[0] (expected object, received number)
items: [[]] accept REFUSE @ items[0] (expected object, received array)
a feed-variant items array accept accept
an ordinary gantt row accept accept

Three corners where validate is now stricter than the renderer — [{ items: null }], [0], [[]] — are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a document validate admits, and is only ever more lenient than validate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.

(c) Fixture census — every in-repo type: 'timeline' document through the tightened mirror

12 documents, 0 new refusals (each accept on main and on head):

examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json
packages/types/examples/data-display-examples.json#examples.timeline
content/docs/plugins/plugin-timeline.mdx fences at lines 36, 168, 203, 246, 327, 342 (object literal extracted by brace-matching, evaluated)
content/docs/api/schema-reference.md inline documents at lines 668, 1093

Population: grep -rl over examples/, packages/*/catalog (none exist), content/docs, packages/*/examples and the schema-catalog tests for type: 'timeline' / "type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned in timeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.

Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed

Committed first; each leg mutated renderer.tsx with an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored with git checkout HEAD -- path proven by git hash-object equal to the HEAD blob (36abf53e…) and an empty git diff HEAD, trap-guarded, absolute paths. No dist is involved (the pin imports ../renderer; vitest aliases workspace packages to src), so no rebuild leg.

  • Leg 1 — drop the items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crashitems: {} / 'x' / 5 rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.
  • Leg 2 — drop the null-row refusal: 3 red / 19 green. Direction: relocation into the normalizerTypeError: Cannot read properties of null (reading 'items') at renderer.tsx:345:26 (classifyGanttRows), exactly the card's relocation pattern.
  • Leg 3 — drop the truthy-non-array row.items refusal: 7 red / 15 green. Direction: relocation into calculateDateRangeTypeError: row.items.flatMap is not a function at renderer.tsx:367:25, and the 'x' row falling back to the DATE copy.

Exotic-class pins that moved (stated, exercised)

Array.isArray in classifyGanttRows runs before U1 (items.length) and U4 (rowItems.length) can. A revoked Proxy dies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throw Cannot perform 'IsArray' on a proxy that has been revoked (pin 4's class) instead of 'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks on findUnusableGanttDate, calculateDateRange, spellGanttDateValue and the gantt branch updated to say what is true now.

Verification (final commit 8e1dc8274)

  • Union, run AFTER the final commit, head echoed by the run: pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/Test Files 168 passed (168) · Tests 2689 passed (2689); os-verify-lock: VERDICT command-exit 0.
  • Type-checks (dependency closure built first, pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." build exit 0): pnpm --filter @object-ui/plugin-timeline run type-check (echoed tsc --noEmit && tsc -p tsconfig.test.json) EXIT=0; @object-ui/types (echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0; @object-ui/i18n EXIT=0. --listFiles on the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.
  • ESLint (--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-pattern no-explicit-any / react-refresh classes; no new rule class).
  • Gates: check-changeset-presence ✅ (16 source files of 3 released packages, 1 changeset declared) · check-changeset-no-major ✅ · check-changeset-fixed ✅ · check:control-bytes ✅ (6056 files) · check:i18n-keys ✅ (every call-site key resolves; 2907 en keys) · check:i18n-drift ✅ (0 en values changed, 1 key added — parity's business, and all-locales-key-parity is green) · check:i18n-dead-keys report unchanged · check:vi-mock-specifiers ✅ · check:vi-mock-inherit ✅.
  • Governed-surface predicate (objectstack/scripts/pm/check-governed-merges.mjs --test on the final 19-path list): 0 of 19 path(s) hit the registerNOT governed.
  • check:eager-closure: NOT MEASURED (needs a full apps/console build; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; the framework chunk's headroom in scripts/check-eager-closure-budget.mjs is 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.

Deviations from the dispatch (each declared, none silent)

  1. The NAMED row's outcome changed.items: [{ items: 'x' }] was NAMED through malformedDate on main by accident (a string is index-readable). The ruling's door is "a row whose items is a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED through malformedRow naming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.
  2. Hole named {{value}}, not {{fault}}. The sibling key in the same block uses {{path}} / {{value}} and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.
  3. ZONE 2 item 2's "row null/non-object" narrowed to null/undefined. ZONE 1 6(a) pins items: [0] and [[]] as drawing CONTROLs, and the ruling names null rows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0], ['x'], [true], [[]]) draws an unlabelled empty row silently — while validate now refuses it #7364) rather than decided here.
  4. The plugin's provider-less default table got the same key (useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror of en for the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.
  5. A third row was added to revokedPositions typing in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.

Out of scope, filed

ObjectTimeline.tsx is read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho


Generated by Claude Code

… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3167.3 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-Eyz6MvMH.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.59KB117.40KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)208.92KB56.59KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

2 participants

@os-litant@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror - #7367

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal
Sep 2, 2026
Merged

fix(plugin-timeline,types,i18n): refuse a malformed gantt row by name instead of crashing, and declare the row shape in the mirror#7367
os-litant merged 1 commit into
mainfrom
claude/issue-7164-gantt-malformed-row-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#7164
Clause-②: yes — contract review required before release

Maintainer ruling 2026-09-02 (director seat, summon #8, comment 5507933270): A+ — refuse the chart through a NEW diagnostic key naming the malformed row and the fault, never the malformedDate copy; AND tighten the zod mirror so null rows and non-array items are refused by validate before a renderer is reached. Option B (skip the row) and C (document) not taken. This PR executes that ruling as written. Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

What changed

Render-time door (packages/plugin-timeline/src/renderer.tsx). One normalizer, classifyGanttRows(items), reads the raw shape ONCE and returns either { ok: true, rows } — a readonly GanttRow[] whose items is an array by construction — or { ok: false, path, value }. Three refusals, in walk order: items not an array → items; a null / undefined row → items[i]; a truthy non-array row.itemsitems[i].items. The three readers the card named — findUnusableGanttDate, calculateDateRange and the render loop — now all consume the verdict; none re-reads schema.items. The refusal renders the existing role="alert" surface (#6759's element, same data-testid) through the new key, with {{path}} and {{value}} (the value spelled by spellGanttDateValue, so no author code runs). onItemClick still receives the author's own row object (pinned).

The keytimeline.gantt.unusableRange.malformedRow, placed directly after malformedDate inside the existing unusableRange block in all ten packs (ar de en es fr ja ko pt ru zh), each a real translation in the register of that pack's malformedDate line; plus the byte-identical English twin in the plugin's provider-less default table (useTimelineTranslation.ts, as malformedDate has). English:

Unusable gantt rows — {{path}} is {{value}}, which is not a row shape. A gantt draws items as a list of rows, every row as an object with a label and its own items, and every row's items as a list of bars; a null, a number, a string or a plain object in any of those places cannot be drawn.

Author-time door (packages/types/src/zod/data-display.zod.ts). TimelineSchema.items is now z.array(TimelineRowSchema) where the (non-exported, per the parity-registry convention) row schema is z.object({ items: z.array(z.any()).optional() }).passthrough(): every element an object; a row's items, when present, an array. Nothing else narrowed — feed items carry no items key and parse as before; bars stay z.any(). The TS twin (data-display.ts) keeps items?: any[] (its docblock already carries both shapes in prose) with a note pointing at the mirror; the parity ledger registered NO drift (any[] is assignable to the narrowed input), so KnownDrift is untouched.

Pins. New timeline-gantt-malformed-row-7164.test.tsx (22 tests: the card's table, controls, order, onItemClick identity, the key); new timeline-items-row-shape-7164.test.ts in packages/types (accept-set table on the new mirror, a rebuilt OLD mirror as the two-sided control, fixture census over the JSON documents). The 7027 pin's pin 6 (which documented these inputs as a defect and said it was expected to go red on repair) now pins the REFUSAL; two rows of its pin 5 moved with the reads they exercise (below). The 6907 pin's "no key was added" it title is corrected to what it asserts.

Changeset.changeset/7164-gantt-malformed-row-refusal.md: @object-ui/plugin-timeline patch · @object-ui/typesminor — the mirror's accept set narrows (stated plainly in the changeset) · @object-ui/i18n patch.

Contract-review pack

(a) The card's table, re-run on 8e1dc8274 through the real TimelineRenderer

RED first, on a67abdc88 (same probe, before any edit):

items: [null] THREW renderer.tsx:287:10 (row.items)
items: [{ items: 5 }] THREW renderer.tsx:287:23 ((row.items || []).flatMap)
items: [{ items: {} }] THREW renderer.tsx:287:23
items: [{ items: true }] THREW renderer.tsx:287:23
items: [{ items: {length:1, 0:{dates}} }] THREW renderer.tsx:287:23
items: {} THREW renderer.tsx:286:26 (items.flatMap)
items: 'x' THREW renderer.tsx:286:26
items: 5 THREW renderer.tsx:286:26
items: [{ items: 'x' }] NAMED items[0].items[0].startDate is undefined, which is not a valid date
CONTROL an ordinary row DREW bars=1 axisCells=3
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

GREEN, on 8e1dc8274 (the new pin asserts each line; 22/22 pass):

items: [null] REFUSED malformedRow "items[0] is null, which is not a row shape"
items: [{ items: 5 }] REFUSED malformedRow "items[0].items is 5, …"
items: [{ items: {} }] REFUSED malformedRow "items[0].items is an object, …"
items: [{ items: true }] REFUSED malformedRow "items[0].items is true, …"
items: [{ items: {length:1, 0:{dates}} }] REFUSED malformedRow "items[0].items is an object, …"
items: {} REFUSED malformedRow "items is an object, …"
items: 'x' REFUSED malformedRow "items is \"x\", …"
items: 5 REFUSED malformedRow "items is 5, …"
items: [{ items: 'x' }] REFUSED malformedRow "items[0].items is \"x\", …" ⚠ CHANGED — see deviation 1
CONTROL an ordinary row DREW bars=1 axisCells=3 (axis Jan/Feb/Mar 2024, unchanged)
CONTROL items: [] DREW bars=0 axisCells=1
CONTROL items: [{ label: 'R' }] DREW bars=0 axisCells=1
CONTROL items: [{ items: null }] DREW bars=0 axisCells=1
CONTROL items: [0] / [[]] DREW bars=0 axisCells=1

Every refusal renders zero bars and zero axis cells, contains the new key's clause and NOT malformedDate's. Order pinned: a null row at items[1] wins over an unparseable date at items[2]; a well-formed row with a bad date still takes malformedDate unchanged.

(b) The mirror's accept set — safeParse, origin/main (a67abdc88) vs head (8e1dc8274)

Measured by a probe that rebuilt the old declaration (z.array(z.any()).optional()) on the same base:

input main head
items: [null] accept REFUSE @ items[0] (expected object, received null)
items: [{ items: 5 }] accept REFUSE @ items[0].items (expected array, received number)
items: [{ items: {} }] accept REFUSE @ items[0].items (expected array, received object)
items: [{ items: { length: 1, 0: {…} } }] accept REFUSE @ items[0].items (expected array, received object)
items: {} REFUSE REFUSE @ items
items: 'x' REFUSE REFUSE @ items
items: [{ items: 'x' }] accept REFUSE @ items[0].items (expected array, received string)
items: [] accept accept
items: [{ label: 'R' }] accept accept
items: [{ items: null }] accept REFUSE @ items[0].items (expected array, received null)
items: [0] accept REFUSE @ items[0] (expected object, received number)
items: [[]] accept REFUSE @ items[0] (expected object, received array)
a feed-variant items array accept accept
an ordinary gantt row accept accept

Three corners where validate is now stricter than the renderer — [{ items: null }], [0], [[]] — are refused at authoring and still DRAW (empty / unlabelled row), because the ruling fixed the render door at three shapes and pinned those rows as drawing CONTROLs. The invariant that holds on both sides: the renderer never crashes on a document validate admits, and is only ever more lenient than validate, never the reverse. The primitive-row corner is filed as #7364 for a ruling; it is not widened here.

(c) Fixture census — every in-repo type: 'timeline' document through the tightened mirror

12 documents, 0 new refusals (each accept on main and on head):

examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/horizontal-timeline.json
examples/schema-catalog/src/schemas/plugin-timeline/vertical-timeline.json
packages/types/examples/data-display-examples.json#examples.timeline
content/docs/plugins/plugin-timeline.mdx fences at lines 36, 168, 203, 246, 327, 342 (object literal extracted by brace-matching, evaluated)
content/docs/api/schema-reference.md inline documents at lines 668, 1093

Population: grep -rl over examples/, packages/*/catalog (none exist), content/docs, packages/*/examples and the schema-catalog tests for type: 'timeline' / "type": "timeline". The mdx fence at line 69 is the schema's TYPE signature (items?: TimelineItem[]), not a document, and is excluded. The three JSON fixtures and the examples JSON are pinned in timeline-items-row-shape-7164.test.ts; the docs fences were censused on this PR.

Ablation — each refusal is load-bearing, and the failure without it is measured, not assumed

Committed first; each leg mutated renderer.tsx with an anchor replacement proven on disk (anchor count 1 → 0, marker 0 → 1, blob hash moved), ran the new pin, and restored with git checkout HEAD -- path proven by git hash-object equal to the HEAD blob (36abf53e…) and an empty git diff HEAD, trap-guarded, absolute paths. No dist is involved (the pin imports ../renderer; vitest aliases workspace packages to src), so no rebuild leg.

  • Leg 1 — drop the items-not-an-array refusal: 4 red / 18 green. Direction: silent DRAW, not a crashitems: {} / 'x' / 5 rendered an empty gantt with no diagnostic (no diagnostic rendered: expected null not to be null), because the normalizer's own walk is tolerant. That is the worse failure mode the refusal prevents.
  • Leg 2 — drop the null-row refusal: 3 red / 19 green. Direction: relocation into the normalizerTypeError: Cannot read properties of null (reading 'items') at renderer.tsx:345:26 (classifyGanttRows), exactly the card's relocation pattern.
  • Leg 3 — drop the truthy-non-array row.items refusal: 7 red / 15 green. Direction: relocation into calculateDateRangeTypeError: row.items.flatMap is not a function at renderer.tsx:367:25, and the 'x' row falling back to the DATE copy.

Exotic-class pins that moved (stated, exercised)

Array.isArray in classifyGanttRows runs before U1 (items.length) and U4 (rowItems.length) can. A revoked Proxy dies at the first operation that touches it, so two of the 7027 pin's four revoked-position rows now throw Cannot perform 'IsArray' on a proxy that has been revoked (pin 4's class) instead of 'get'; the trap-message rows U1–U6 are unchanged because the trap writes the message. Reachability argument unchanged: JSON cannot spell a proxy. Docblocks on findUnusableGanttDate, calculateDateRange, spellGanttDateValue and the gantt branch updated to say what is true now.

Verification (final commit 8e1dc8274)

  • Union, run AFTER the final commit, head echoed by the run: pnpm exec vitest run --maxWorkers=2 packages/plugin-timeline/ packages/types/ packages/i18n/Test Files 168 passed (168) · Tests 2689 passed (2689); os-verify-lock: VERDICT command-exit 0.
  • Type-checks (dependency closure built first, pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-timeline^..." build exit 0): pnpm --filter @object-ui/plugin-timeline run type-check (echoed tsc --noEmit && tsc -p tsconfig.test.json) EXIT=0; @object-ui/types (echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json) EXIT=0; @object-ui/i18n EXIT=0. --listFiles on the test tsconfigs: the three edited plugin-timeline test files present (3), the new types test present (1) — the new tests are in the compiled set.
  • ESLint (--no-inline-config, JSON format) over the 18 changed source/test files: errors 0, warnings 50 (all pre-existing-pattern no-explicit-any / react-refresh classes; no new rule class).
  • Gates: check-changeset-presence ✅ (16 source files of 3 released packages, 1 changeset declared) · check-changeset-no-major ✅ · check-changeset-fixed ✅ · check:control-bytes ✅ (6056 files) · check:i18n-keys ✅ (every call-site key resolves; 2907 en keys) · check:i18n-drift ✅ (0 en values changed, 1 key added — parity's business, and all-locales-key-parity is green) · check:i18n-dead-keys report unchanged · check:vi-mock-specifiers ✅ · check:vi-mock-inherit ✅.
  • Governed-surface predicate (objectstack/scripts/pm/check-governed-merges.mjs --test on the final 19-path list): 0 of 19 path(s) hit the registerNOT governed.
  • check:eager-closure: NOT MEASURED (needs a full apps/console build; not run under the foreground cap). Estimate: the ten added lines total 4,037 bytes raw, 1,960 bytes gzip-9 in isolation; the framework chunk's headroom in scripts/check-eager-closure-budget.mjs is 524,000 − 514,863 = 9,137 bytes. One key × ten packs fits with ~7 KB to spare; CI weighs it.

Deviations from the dispatch (each declared, none silent)

  1. The NAMED row's outcome changed.items: [{ items: 'x' }] was NAMED through malformedDate on main by accident (a string is index-readable). The ruling's door is "a row whose items is a TRUTHY NON-ARRAY", and a string is one, so it is now REFUSED through malformedRow naming the true fault (items[0].items is "x"). Keeping it on the date copy would have required a string-specific exemption preserving what the card itself called an undesigned asymmetry. Pinned explicitly as the one row whose outcome, not its crash, changed.
  2. Hole named {{value}}, not {{fault}}. The sibling key in the same block uses {{path}} / {{value}} and the same speller; the "fault" is carried by the sentence ("which is not a row shape"), which reads correctly at all three path levels (pinned) — one key, no untranslatable clause pushed through a hole.
  3. ZONE 2 item 2's "row null/non-object" narrowed to null/undefined. ZONE 1 6(a) pins items: [0] and [[]] as drawing CONTROLs, and the ruling names null rows; a non-object predicate would flip those controls. The corner is filed (finding(plugin-timeline): a gantt row that is a non-null primitive or an array (items: [0], ['x'], [true], [[]]) draws an unlabelled empty row silently — while validate now refuses it #7364) rather than decided here.
  4. The plugin's provider-less default table got the same key (useTimelineTranslation.ts) — not on the dispatch's file list, but the package's own mirror of en for the two sibling keys, and without it the provider-less host (every unit test) renders the bare key.
  5. A third row was added to revokedPositions typing in the 7027 pin (message per row) so the two moved sites are asserted by their new message rather than loosened.

Out of scope, filed

ObjectTimeline.tsx is read, not edited, per the ruling. Draft: stays draft pending the in-seat contract review; not marked ready, no auto-merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho


Generated by Claude Code

… instead of crashing, and declare the row shape in the mirror
A gantt whose `items` is not an array, whose row is `null`, or whose
`row.items` is a truthy non-array crashed the render with a `TypeError`
from ordinary JSON that the declared zod mirror accepted:
`findUnusableGanttDate` read the row walk defensively and
`calculateDateRange` re-read it bare one line later. A guard in either
reader only relocated the crash into the render loop (ablation-proven on
the card, four prior relocations on this path).
Render-time door: `classifyGanttRows` reads the raw shape ONCE and either
refuses it through a new key, `timeline.gantt.unusableRange.malformedRow`
("items[0] is null, which is not a row shape"), naming the authored path
and the value, or hands the three readers (the date scan, the range
computation, the render loop) one normalized `GanttRow[]`. Never the
`malformedDate` copy, which named the wrong fault. The key lands in `en`
and the nine sibling locale packs, and in the plugin's provider-less
default table as `en`'s byte-identical twin.
Author-time door: `TimelineSchema.items` declares every element an object
and a row's own `items`, when present, an array, so `validate` refuses
`items: [null]` and `items: [{ items: 5 }]` before a renderer is reached.
Feed items carry no `items` key and parse as before; every in-repo
`type: 'timeline'` fixture parses green on both sides.
The card's THREW table is re-run as a pin (each row REFUSED at its path,
the five CONTROL rows drawing with unchanged counts); the 7027 pin's rows
that documented these inputs as a defect now pin the refusal, and its two
revoked-proxy rows moved with the reads they exercise.
Maintainer ruling 2026-09-02 (A+), objectui#7164.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3167.3 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-Eyz6MvMH.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.59KB117.40KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)208.92KB56.59KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Projects

None yet

2 participants

@os-litant@claude