Skip to content

fix(plugin-gantt): pin every restated GanttConfig member against its twin - #6546

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6471-ganttconfigex-duplicates
Aug 26, 2026
Merged

fix(plugin-gantt): pin every restated GanttConfig member against its twin#6546
os-support-ai merged 2 commits into
mainfrom
claude/issue-6471-ganttconfigex-duplicates

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6471

Verified at 8ec2e80cd (the final commit — the gate union below was re-run on this exact tree).

The measurement, re-derived on current main — the card's number is stale

The card says eleven restated members; triage repeats it. On main at c18acb09e, GanttConfigEx is a type alias at ObjectGantt.tsx:106 declaring twelve:

parentField · typeField · baselineStartField · baselineEndField · groupByField · resourceView · assigneeField · effortField · capacity · quickFilters · autoZoomToFilter · timeSegments

The card's list of eleven omitted timeSegments (it discussed that one separately), and its "nine pure duplicates" is one short of its own list. Twelve is the count that stands.

Grep note, since the dispatch order asked for a positive control: grep -c "interface GanttConfigEx" returns 0 — the declaration is a type. The positive control grep -n "GanttConfigEx" returns 5 hits (:106, :332, :337, :455, :459).

What the measurement contradicts: neither "narrowing" narrows anything

The charter keeps quickFilters and timeSegments as load-bearing NARROWINGS. Measured with a compiled type-level probe, all twelve members — those two included — are mutually assignable with their GanttConfig twin on current main. They narrow nothing today, and the reason is traceable rather than mysterious:

They are kept anyway, per the charter, and both are now pinned as a measured state rather than as inherited prose. The probe was proven non-vacuous before this claim was believed: seven controls, all of which had to fail and did — Mutual<string, number> refused, a cross-key mismatch refused, and neither operand of either pair being any (an any on either side makes the comparison unconditionally true).

Route: the pin, not the JSDoc lift — chosen by measurement

Option A ("lift the JSDoc onto GanttConfig, delete the restatements") is refuted, not disliked. Nine of the twelve keys arrive on GanttConfig from SpecGanttConfig = z.input<typeof GanttConfigSchema>, a type declared in @objectstack/spec. TypeScript cannot attach JSDoc to a member a type merely inherits, and the spec emits no per-member documentation of its own. So Option A has exactly two spellings, and both are refused:

  1. Delete the members and their prose → the documentation is lost, which the charter forbids and which is the reason the restatements exist.
  2. Re-declare the members on GanttConfig to carry the JSDoc → this does not remove a duplicate declaration, it relocates it out of a package-private type into the published @object-ui/types, onto the very type both authoring faces derive from. Per clause ②, that is a widened published surface and a different review tier.

Option B — the type-level mutual-assignability pin — closes the defect with neither cost. The prose stays exactly where it is (the diff is +30 / −1 on ObjectGantt.tsx: pure addition, no line of documentation deleted), and no published surface moves.

Derivation (parentField?: GanttConfig['parentField'], the pattern #6051 used for the flat face) was considered and rejected for these keys: GanttConfig carries [x: string]: unknown because its spec schema is $loose, so an indexed access into it never errors — a key the spec removed would silently resolve to unknown rather than failing. Deriving would also make the pin vacuous by construction, since both operands would become the same expression. The pin is what keeps a spec bump loud.

The mechanism: naming the local half is the whole fix

The pin is impossible to write against GanttConfigEx, and writing it that way is the trap:

typeGanttConfigEx=GanttConfig&{parentField?: string;/* … */};

GanttConfigEx['parentField'] is alreadyGanttConfig['parentField'] & string, so it is assignable to GanttConfig['parentField']by construction. An assertion phrased over the intersection is green forever, no matter how far the two declarations drift — it measures nothing. Splitting the local half into a named GanttConfigRestated is what gives the pin two independent operands.

The core invariant is derived over keyof GanttConfigRestated, so there is no key list to fall out of date, and a violation names the key in the compiler error.

Reverse verification — both legs, mutation proven on disk

Neither leg trusts an editor's exit code; each mutation was confirmed by anchored grep counts on both the injected and the removed text, plus a git hash-object comparison against the HEAD blob. Both scripts carried trap … EXIT INT TERM with absolute paths, and each restoration was proven byte-identical to the HEAD blob (not by the restore command's exit code).

Leg 1 — the pin has teeth. Predicted direction: red, naming the key. Mutated capacity?: numbercapacity?: string (number; count 1→0, string; count 0→1, disk hash ≠ HEAD blob), then compiled:

src/ObjectGantt.configPin.test.ts(117,11): error TS2322: Type 'true' is not assignable to type '"capacity"'.

The error names capacity — that is the instrument doing its job. Restored: disk hash e39b5b07… = HEAD blob e39b5b07….

Leg 2 — the vacuity control guards itself. Predicted direction: TS2578. Mutated Mutual into an always-true form, then compiled:

src/ObjectGantt.configPin.test.ts(139,5): error TS2578: Unused '@ts-expect-error' directive.

So the @ts-expect-error in the non-vacuity test is live: if Mutual ever stops having teeth, the directive goes unused and the build fails. Restored: disk hash 830af148… = HEAD blob 830af148….

No @ts-expect-error directive elsewhere was disturbed — packages/typestype-check (which compiles gantt-flat-config-declared-keys.test.ts, whose :354 / :358 directives pin quickFilters and timeSegments) is clean, with grep -c 'error TS' = 0. This change touches zero files under packages/types.

Clause ② — established on built dist/index.d.ts, not on source keywords

  • @object-ui/types: no member added, removed or re-typed. The diff touches zero files in that package. Re-export chain shown to terminate: dist/index.d.ts:72 re-exports GanttConfigfrom './objectql.js'dist/objectql.d.ts:141export type GanttConfig = SpecGanttConfig & {. That is the terminus, and it is untouched.
  • @object-ui/plugin-gantt: GanttConfigRestated does not reach the published entry. After a real vite build, packages/plugin-gantt/dist/index.d.ts carries 0 occurrences of it (index.tsx re-exports named symbols explicitly and this is not among them). Positive control on the same file: QuickFilterDef, which is re-exported, appears 1 time — so the grep can find what is there.

No published surface changes, which is precisely why this route was chosen over the JSDoc lift.

Verification

Every heavy command ran through the container's shared verify lock; each verdict below is the gate's own printed line, and exit codes were captured before any pipe.

GateResult
pnpm --filter @object-ui/plugin-gantt run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
vitest run — pin suite + quickfilter + shifts + blockPrecedenceTest Files 4 passed (4) · Tests 45 passed (45)
vitest run — pin suite alone (non-vacuity: it really runs)Test Files 1 passed (1) · Tests 6 passed (6)
pnpm --filter @object-ui/types run type-checkclean, error TS count 0 — no TS2578
check-changeset-presence✅ … declares 1 changeset(s) … EMPTY frontmatter … a complete answer to this gate
check-changeset-no-major✅ No changeset declares a 'major' bump.
check:control-bytes · check:self-import · check:phantom-deps · check:vi-mock-specifiers · lint:coverageexit 0 each
pnpm --filter @object-ui/plugin-gantt lintVERDICT command-exit 0 — 0 errors; the new file contributes 0 findings

Lint scope. The affected package was linted whole (turbo run lint's own unit), so nothing was narrowed inside it. Only packages/plugin-gantt has changed files, and no other package's lint program input moved — established above on the built dist/index.d.ts, not assumed.

One gate is NOT MEASURED, and is reported as such rather than as green.check:readme-exports exits 1 with its own ❌ the population COLLAPSED -- this run proves nothing, naming the cause: packagesRead: found 14, floor is 25, with 23 unbuilt. It needs a full-workspace build, which CI performs. It reads no file this diff touches (no README changed; no package entry export added). Recorded as a prerequisite-not-met, not as a passing or failing measurement.

Also self-scanned both changed files and the changeset for raw control bytes (grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'): no matches.

Scope

#6470 — the dependencyField alias card in the same file family — is not addressed here and remains open, as the dispatch order directed. packages/types/src/feedback.ts and packages/types/src/zod/feedback.zod.ts were not touched.

Generated by Claude Code


Generated by Claude Code

…twin
`GanttConfigEx` restates twelve members `GanttConfig` already declares. Two
declarations of one key can disagree, and nothing asserted they still agree
after a spec bump.
Name the local half (`GanttConfigRestated`) so the two declarations become
independent operands — inside the intersection `GanttConfigEx[K]` is already
`GanttConfig[K] & local[K]`, so any assertion phrased over it passes by
construction — and pin mutual assignability over every key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
…g nothing
Type-only and package-private: no runtime change, and the published surface is
untouched on both sides (packages/types has zero changed files; the new
GanttConfigRestated alias is not re-exported by the package entry).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.4 KB3266.6 KB
Main entry chunk (gzip)157.0 KB350 KB
Entry fileindex-BDDe7Ree.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)11.71KB4.46KB
app-shell (runtime-config.js)18.10KB6.51KB
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)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
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.95KB10.97KB
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)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
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)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.76KB43.14KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
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)63.21KB21.05KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
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)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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

@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — objectui#6471 (domain:ui lane, PM review). Reviewed from the tree at 8ec2e80cd.

Two of the card's stated facts are measured false, and the dev's measurements win on both. Independently re-verified here.

The count is twelve, not eleven

Read from the declaration to its closing brace rather than a fixed window, GanttConfigEx on main declares 12 members:

parentField typeField baselineStartField baselineEndField groupByField
resourceView assigneeField effortField capacity quickFilters
autoZoomToFilter timeSegments

Both quickFilters and timeSegments are present. The card's list of eleven omitted one, which is also why its arithmetic does not close — it lists 11, names 2 narrowings, and concludes 9 pure duplicates, while the actual population is 12.

⭐ My own first count said 10, because I sliced a fixed 40 lines from the declaration instead of reading to the brace. Same class of error as the grep warning I put in the dispatch order, one instrument over: a window that cannot reach the end of the thing you are counting returns a number that looks like a count. Corrected before reporting, and worth stating because it is the third time today the seat has produced a wrong reading from a pattern that could not see the whole target.

The "load-bearing narrowings" are not narrowings any more

Triage's charter said keep quickFilters and timeSegments because they pin the plugin's runtime types. Measured, all twelve members are mutually assignable with their GanttConfig twin — and the dev traced why rather than just reporting it: #6051/#6472 lifted timeSegments onto GanttConfig in a shape structurally identical to ShiftSegmentsConfig, and rc.6's GanttConfigSchema.quickFilters already models field/label/options exactly as QuickFilterDef does. The justification lapsed when those landed; the prose did not.

⭐⭐ And the probe was proven non-vacuous before the result was believed — 7 controls, every one required to fail and every one did, including any-detection on both operands of both pairs. A mutual-assignability probe where either side has degraded to any passes trivially and tells you nothing. Establishing that first is what makes the negative result admissible.

They are kept per the charter, now pinned as a measured state instead of inherited prose. That is the right disposition: the claim that was rotting is replaced by a claim that cannot rot silently.

⭐⭐⭐ Option A is refuted, not declined

This is the finding I would keep from this card. Triage offered two routes and said the dev picks by measurement. Route A — lift the JSDoc onto GanttConfig, delete the restatements — turns out to be impossible in both of its spellings:

  • Nine of the twelve keys arrive from SpecGanttConfig = z.input<typeof GanttConfigSchema>, which emits no per-member JSDoc, and TypeScript cannot document a member a type merely inherits. So "lift the prose onto GanttConfig" has nowhere to lift it to.
  • The only way to make it work is re-declaring those members on the published GanttConfig — which does not remove a duplicate declaration, it relocates it into @object-ui/types. That is a widened published surface, Clause-②, a different review tier.

So the obvious tidy-up was a surface widening wearing a refactor's clothes. That is worth more than the pin itself.

The pin's mechanism is the part that makes it real

⭐⭐⭐ A pin phrased over GanttConfigEx is vacuous by construction: GanttConfigEx[K] is already GanttConfig[K] & local[K], hence assignable to GanttConfig[K] no matter how far the two drift. So the fix names the local half (GanttConfigRestated, verified at ObjectGantt.tsx:135, with :106 now reading GanttConfigEx = GanttConfig & GanttConfigRestated) and derives the invariant over keyof GanttConfigRestatedno key list to rot, and a violation names the key.

Derivation (parentField?: GanttConfig['parentField']) was considered and rejected with a reason, not a preference: GanttConfig carries [x: string]: unknown because its spec schema is $loose, so indexed access never errors and a removed spec key would silently resolve to unknown. That is the fourth index-signature blindness this lane has met today, and the first caught in the design phase rather than in a landed pin.

Reverse verification: both legs flip the compiler, and that is the proof

  • Leg 1capacity?: numberstring: TS2322: Type 'true' is not assignable to type '"capacity"'. It names the key, which is the whole point of deriving over keyof.
  • Leg 2Mutual mutated to an always-true form: TS2578: Unused '@ts-expect-error' directive. The vacuity control guards itself.

⭐ And the reason no rebuild was needed is stated rather than assumed: tsconfig.test.json sets paths: {}, so the unmutated side resolves to built dist while the mutated side is a relative source import tsc reads directly — and each leg was observed to flip the verdict, which is itself proof the mutation was seen. As the dev puts it, a stale-artifact ablation fails GREEN, the dangerous direction. Both restorations proven byte-identical to the HEAD blob, not by a restore command's exit code.

Clause-② established on built artifacts

packages/types: zero changed files (verified). The re-export chain shown to terminatedist/index.d.ts:72dist/objectql.d.ts:141. plugin-gantt's built dist/index.d.ts carries 0 occurrences of GanttConfigRestated, with positive control QuickFilterDef = 1 on the same file. No published surface changed.

Documentation provably not lost: +30/-1 on ObjectGantt.tsx, verified — pure addition, no prose deleted. The charter's ⛔ held.

One gate not measured, reported in the gate's own words

check:readme-exports exits 1 saying "the population COLLAPSED -- this run proves nothing", packagesRead: found 14, floor is 25, 23 unbuilt. A gate that declares its own run invalid is not a red gate. Booked NOT MEASURED.

Your open question

Recorded, not ruled. Option A is what shipped and I agree it is right for this PR: it obeys the charter's ⛔ while making the stale "load-bearing narrowing" claim non-inheritable, which is the durable half. Whether the two ex-narrowings should later be treated differently is a scope question for triage, and #6547 already captures the adjacent one. Not blocking.

On the claim-protocol conflict you flagged: you assigned yourself; the dev on #6522 did the opposite under the same contract. That inconsistency is the substance of objectstack#12520 and I have carried your data point there.

CI: 29 checks, zero failed at review time. Landing on green.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review August 26, 2026 10:24
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 64e32e6Aug 26, 2026
30 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6471-ganttconfigex-duplicates branch August 26, 2026 10:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-support-ai@claude