Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/form-field-slot-typed-and-import-tripwire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@object-ui/types": patch
"@object-ui/components": patch
"@object-ui/plugin-form": patch
---

fix(form): the runtime `field` metadata slot is declared instead of smuggled, and importing the spec's FormField is a lint error — #3090

`FormField.field` — the slot where object-bound form paths stash the resolved
field-metadata **object** for widgets — rode through the index signature,
undeclared, readable only via `as any`. Same key, different layer: in the spec
form-view vocabulary `field` is a *string* (the referenced object-field name),
and the undeclared slot kept that pun latent. The slot is now declared
(`field?: Record<string, any>`) with the invariant in its JSDoc: on a runtime
FormField it is never a string — the authored string form ends at the
`normalizeSectionField` chokepoint, and a tripwire test pins that across all
three input shapes. Assigning a string is now a compile error; the `as any`
casts at the read sites are gone.

A `no-restricted-imports` tripwire bans importing `FormField`/
`FormFieldSchema` from `@objectstack/spec/ui` inside this repo: the spec's
FormField TYPE erases to `any` in its dist (objectstack#4171), so the
misimport silently deletes type safety — tsc says nothing. The lint message
names the two layers and the correct import. The drift-guard parity test is
the one legitimate importer, exempted inline with its reason.

Ledger: `FormField` and `FormFieldSchema` move from untriaged DEBT to ALLOW
with the two-layer rationale written down (122 → 120).
19 changes: 19 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ export default tseslint.config({
// Error so a tenth copy fails CI; all known sites were converted first, so
// this lints clean today.
'object-ui/no-try-catch-around-hook': 'error',
// objectui#3090 tripwire — the spec's FormField/FormFieldSchema are the
// form-VIEW vocabulary (`field` = object-field reference), a DIFFERENT
// layer from objectui's runtime form-field contract (`name` = data path);
// the translation point is `normalizeSectionField` in @object-ui/
// plugin-form. Worse, the spec's FormField TYPE erases to `any` in its
// dist (objectstack#4171), so importing it here silently deletes type
// safety — tsc says nothing. Error so the misimport fails at write time,
// with this message as the correction.
'no-restricted-imports': ['error', {
paths: [{
name: '@objectstack/spec/ui',
importNames: ['FormField', 'FormFieldSchema'],
message:
'This is the spec form-VIEW vocabulary (field = object-field reference), and its type erases to ' +
'`any` (objectstack#4171) — importing it silently deletes type safety. The runtime form-field ' +
'contract is `FormField`/`FormFieldSchema` from @object-ui/types; the two layers meet only in ' +
'`normalizeSectionField` (@object-ui/plugin-form). See objectui#3090.',
}],
}],
},
}, {
// objectui#3010/#3021 ratchet — a module loaded inside beforeAll/beforeEach
Expand Down
12 changes: 8 additions & 4 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1118,7 +1118,9 @@ ComponentRegistry.register('form',
// ObjectForm) pass the field metadata through without hoisting
// its `widget` to the top-level form-config, which would
// otherwise degrade a picker field to its raw `type` input.
const resolvedType = widget || (fieldProps as any).field?.widget || type;
// (`.field` is the resolved metadata OBJECT — declared on FormField
// since #3090, never the spec string; see types/form.ts)
const resolvedType = widget || fieldProps.field?.widget || type;

// Cascading / role-gated option lists (#2284). For option fields,
// narrow the set by each option's `visibleWhen` (evaluated against
Expand Down Expand Up @@ -1251,9 +1253,11 @@ ComponentRegistry.register('form',
{/* Render the actual field component based on resolved type */}
{renderFieldComponent(resolvedType, {
...fieldProps,
// specialized fields needs raw metadata, but we should traverse down if it exists
// field is the field configuration loop variable
field: (field as any).field || field,
// Specialized fields need the raw metadata object. `.field`
// is the declared metadata slot (#3090 — never the spec
// string); fall back to the field config itself when no
// metadata was stashed (standalone forms).
field: field.field || field,
...formField,
inputType: fieldProps.inputType,
options: isOptionField ? effectiveOptions : fieldProps.options,
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/actions/__tests__/actionKeys.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import * as SpecUI from '@objectstack/spec/ui';
import { ActionSchema as SpecActionSchema } from '@objectstack/spec/ui';
import {
ACTION_DEF_KEYS,
SPEC_ACTION_KEYS,
Expand Down Expand Up @@ -69,7 +69,7 @@ function specActionKeys(): string[] {
}
return null;
};
const keys = walk(SpecUI.ActionSchema);
const keys = walk(SpecActionSchema);
if (!keys) throw new Error('could not resolve @objectstack/spec/ui ActionSchema shape');
return keys;
}
Expand Down Expand Up @@ -99,7 +99,7 @@ describe('action key inventory (objectstack#4075 step 1)', () => {
});

it('`execute` is still a live spec tombstone, so it must not count as known', () => {
const parsed = SpecUI.ActionSchema.safeParse({
const parsed = SpecActionSchema.safeParse({
name: 'mark_done',
label: 'Mark Done',
type: 'script',
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,10 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
// predicate must be merged onto the resolved field or it is silently
// dropped — the form renderer evaluates it with the canonical engine.
const sectionDefByName = new Map<string, any>(
// AUTHORED section defs, pre-normalization — here `field` may
// legitimately be the spec identity STRING (the cast is the boundary,
// not a leak; on runtime FormFields the declared `field` slot is
// always the metadata object, #3090).
section.fields.map(f => [typeof f === 'string' ? f : ((f as any).field ?? f.name), f]),
);
const sectionFieldNames = Array.from(sectionDefByName.keys());
Expand Down
5 changes: 5 additions & 0 deletions packages/plugin-form/src/sectionFields.spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
*/

import { describe, it, expect } from 'vitest';
// The drift guard is the ONE legitimate importer of the spec's FormFieldSchema
// in this repo: it exists to enumerate the spec's key set. Everywhere else the
// import is a layer violation — see the no-restricted-imports entry in
// eslint.config.js (#3090).
// eslint-disable-next-line no-restricted-imports
import { FormFieldSchema as SpecFormFieldSchema } from '@objectstack/spec/ui';
import { mapFieldTypeToFormType } from '@object-ui/fields';
import { normalizeSectionField } from './sectionFields';
Expand Down
19 changes: 18 additions & 1 deletion packages/plugin-form/src/sectionFields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('normalizeSectionField', () => {
expect(f.type).toBe(mapFieldTypeToFormType('text')); // merged from object schema
expect(f.required).toBe(true); // spec override
expect((f as any).colSpan).toBe(2); // spec override
expect((f as any).field).toMatchObject({ type: 'text' }); // metadata object, not the string
expect(f.field).toMatchObject({ type: 'text' }); // metadata object, not the string
});

it('merges select options + label from the object schema', () => {
Expand Down Expand Up @@ -162,6 +162,23 @@ describe('normalizeSectionField', () => {
expect(f.dependsOn).toBe('country');
});

it('never emits a string `field` — the spec identity key ends at this boundary', () => {
// On a runtime FormField the declared `field` slot holds the resolved
// metadata OBJECT (or nothing) — never the spec's string reference. This
// is the invariant that makes the same-key pun safe (#3090): the string
// form exists only in AUTHORED defs, and this chokepoint is where it dies.
const shapes: Array<string | Record<string, any>> = [
'industry', // string shorthand
{ field: 'industry', required: true }, // spec object
{ field: 'ghost' }, // spec object, unknown to the schema
{ name: 'custom', type: 'text' }, // already-runtime object
];
for (const def of shapes) {
const out = normalizeSectionField(def as any, ctx);
expect(typeof out.field, `string field leaked for ${JSON.stringify(def)}`).not.toBe('string');
}
});

it('warns once when an entry mixes both vocabularies, and the spec key wins', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/__tests__/spec-derived-unions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@ import {
WindowFunction as SpecWindowFunction,
} from '@objectstack/spec/data';
import type { JoinNode as SpecJoinNode } from '@objectstack/spec/data';
// The objectstack#4171 inverted pin must import the banned name to probe its
// any-ness — this guard is a sanctioned importer (#3090 tripwire).
/* eslint-disable no-restricted-imports -- reported at the specifier line, out of -next-line reach */
import type {
NavigationItem as SpecNavigationItem,
FormField as SpecFormField,
} from '@objectstack/spec/ui';
/* eslint-enable no-restricted-imports */
import type { BreakpointName } from '../mobile';
import type { ExportJobStatus, ImportJobStatus, ImportWriteMode, ValidationError } from '../data';
import type { JoinStrategy, WindowFunction } from '../data-protocol';
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/__tests__/spec-ui-schema-reexports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
// This re-export parity guard walks the WHOLE spec/ui surface — the namespace
// import is its instrument, a sanctioned importer (#3090 tripwire).
// eslint-disable-next-line no-restricted-imports
import * as SpecUI from '@objectstack/spec/ui';
import * as Types from '../index';

Expand Down
17 changes: 17 additions & 0 deletions packages/types/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,23 @@ export interface FormField {
* @example "record.status == 'sent'"
*/
requiredWhen?: string | { dialect?: string; source: string };
/**
* The resolved object-field metadata **object** (typically a
* {@link FieldMetadata} / server-served field definition), stashed by the
* object-bound form paths so widgets can read `precision`, `currency`,
* `reference_to`, `depends_on`, … It feeds the field-widget `field` prop.
*
* ⚠️ Same key, different layer: in the SPEC form-view vocabulary `field` is
* a **string** (the referenced object-field name). That authored shape ends
* at the `normalizeSectionField` chokepoint in `@object-ui/plugin-form` —
* on a runtime FormField this slot is never a string, and its tripwire test
* pins that. Declared (rather than ridden through the index signature) so
* assigning a string here is a compile error instead of a latent pun
* (#3090). Typed loosely because the stash's source is the server-served
* object schema, whose key set is wider than the designer-oriented
* {@link FieldMetadata} union.
*/
field?: Record<string, any>;
/**
* Additional field-specific props
*/
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,9 @@ export {
* ```
*/
export type * as Data from '@objectstack/spec/data';
// Deliberate public namespace export of the spec vocabulary. Note the #4171
// caveat: `UI.FormField` erases to `any` until the spec types its unions.
// eslint-disable-next-line no-restricted-imports
export type * as UI from '@objectstack/spec/ui';
export type * as System from '@objectstack/spec/system';
export type * as AI from '@objectstack/spec/ai';
Expand Down Expand Up @@ -1106,11 +1109,15 @@ export type {
// ============================================================================
// v3.0.8 Spec UI Types — Form View (P1.2)
// ============================================================================
// Deliberate public aliases (the `Spec` prefix IS the disambiguation the
// #3090 tripwire exists to force). #4171 caveat: SpecFormField is `any` today.
/* eslint-disable no-restricted-imports -- reported at the specifier line, out of -next-line reach */
export type {
FormView as SpecFormView,
FormSection as SpecFormSection,
FormField as SpecFormField,
} from '@objectstack/spec/ui';
/* eslint-enable no-restricted-imports */

// ============================================================================
// v3.0.8 Spec UI Types — ListView (P1.1)
Expand Down
25 changes: 23 additions & 2 deletions scripts/check-spec-symbol-derivation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,29 @@ const ALLOW = {
"shape kept for backward compatibility and slated for removal in a future major.",
issue: 4115,
},
"@object-ui/types:FormField": {
reason:
"Two-LAYER vocabulary, not two dialects of one concept (objectui#3090): the spec's " +
"FormField is the authored form-VIEW shape (`field` = object-field reference, presentation " +
"deltas only) while this is the runtime widget config (`name` = form data path, " +
"self-contained). Not derivable — and the spec's FormField type erases to `any` in its dist " +
"(objectstack#4171), so a re-export would delete typing outright. The layers meet only in " +
"`normalizeSectionField` (@object-ui/plugin-form), gated by " +
"sectionFields.spec-parity.test.ts (per-key behavioral coverage of the spec key set, both " +
"directions). Misimport of the spec names is banned by the no-restricted-imports entry in " +
"eslint.config.js.",
issue: 4115,
},
"@object-ui/types:FormFieldSchema": {
reason:
"Zod twin of the two-layer FormField split (objectui#3090): validates the RUNTIME " +
"vocabulary (`name` + widget `type`) that `objectui validate` enforces; the spec's " +
"FormFieldSchema validates the authored form-view layer. Key set pinned by " +
"packages/types/src/__tests__/form-field-zod-coverage.test.ts; the spec-shape rejection " +
"(`{ field: … }` stays invalid here) is pinned there too, and the CLI names the boundary " +
"in its error output instead of suggesting a lossy rename.",
issue: 4115,
},
"@object-ui/types:SelectOptionSchema": {
reason:
"Spec-derived dialect (objectui#3090): spec keys flow in by reference via " +
Expand Down Expand Up @@ -163,8 +186,6 @@ const DEBT = {
"DatasourceSchema",
"DriverInterface",
"FileMetadata",
"FormField",
"FormFieldSchema",
"FormatValidation",
"GestureConfig",
"GestureType",
Expand Down
Loading