Skip to content

Commit 94e63ef

Browse files
os-zhuangclaude
andauthored
fix(form): declare the runtime field metadata slot, ban the spec FormField misimport (#3090 PR3a) (#3097)
* fix(form): declare the runtime field metadata slot, ban the spec FormField misimport (#3090) FormField.field — where object-bound paths stash the resolved field-metadata OBJECT for widgets — rode through the index signature, undeclared, readable only via `as any`, while the same key in the spec form-view vocabulary is a STRING. Declaring it (Record<string, any> + load-bearing JSDoc) makes assigning a string a compile error, retires the casts at every read site, and a tripwire test pins the invariant: normalizeSectionField never emits a string field — the authored form ends at that boundary. The one deliberate boundary read in ObjectForm (authored defs, where the string IS legitimate) is commented as such. no-restricted-imports bans FormField/FormFieldSchema imports from @objectstack/spec/ui: the spec type erases to `any` (objectstack#4171), so the misimport silently deletes type safety and tsc says nothing — the lint message is the correction, naming both layers and the chokepoint. The parity drift guard is the one legitimate importer, exempted inline; the rule firing on it was the mutation proof. Ledger: FormField + FormFieldSchema move DEBT → ALLOW with the two-layer rationale (122 → 120, 5 declared dialects). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): named spec import in the action-keys pin test — a namespace import of spec/ui now trips the #3090 misimport tripwire Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(types): reasoned exemptions for the four sanctioned spec/ui importers the #3090 tripwire flagged Two guard tests (the objectstack#4171 inverted pin and the re-export parity walk) must import the banned names to do their jobs; index.ts's Spec-prefixed public aliases and the UI namespace export ARE the disambiguation the tripwire exists to force. Block-form disables where the rule reports at the specifier line, out of -next-line reach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 49e5671 commit 94e63ef

12 files changed

Lines changed: 139 additions & 10 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@object-ui/types": patch
3+
"@object-ui/components": patch
4+
"@object-ui/plugin-form": patch
5+
---
6+
7+
fix(form): the runtime `field` metadata slot is declared instead of smuggled, and importing the spec's FormField is a lint error — #3090
8+
9+
`FormField.field` — the slot where object-bound form paths stash the resolved
10+
field-metadata **object** for widgets — rode through the index signature,
11+
undeclared, readable only via `as any`. Same key, different layer: in the spec
12+
form-view vocabulary `field` is a *string* (the referenced object-field name),
13+
and the undeclared slot kept that pun latent. The slot is now declared
14+
(`field?: Record<string, any>`) with the invariant in its JSDoc: on a runtime
15+
FormField it is never a string — the authored string form ends at the
16+
`normalizeSectionField` chokepoint, and a tripwire test pins that across all
17+
three input shapes. Assigning a string is now a compile error; the `as any`
18+
casts at the read sites are gone.
19+
20+
A `no-restricted-imports` tripwire bans importing `FormField`/
21+
`FormFieldSchema` from `@objectstack/spec/ui` inside this repo: the spec's
22+
FormField TYPE erases to `any` in its dist (objectstack#4171), so the
23+
misimport silently deletes type safety — tsc says nothing. The lint message
24+
names the two layers and the correct import. The drift-guard parity test is
25+
the one legitimate importer, exempted inline with its reason.
26+
27+
Ledger: `FormField` and `FormFieldSchema` move from untriaged DEBT to ALLOW
28+
with the two-layer rationale written down (122 → 120).

eslint.config.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,25 @@ export default tseslint.config({
6363
// Error so a tenth copy fails CI; all known sites were converted first, so
6464
// this lints clean today.
6565
'object-ui/no-try-catch-around-hook': 'error',
66+
// objectui#3090 tripwire — the spec's FormField/FormFieldSchema are the
67+
// form-VIEW vocabulary (`field` = object-field reference), a DIFFERENT
68+
// layer from objectui's runtime form-field contract (`name` = data path);
69+
// the translation point is `normalizeSectionField` in @object-ui/
70+
// plugin-form. Worse, the spec's FormField TYPE erases to `any` in its
71+
// dist (objectstack#4171), so importing it here silently deletes type
72+
// safety — tsc says nothing. Error so the misimport fails at write time,
73+
// with this message as the correction.
74+
'no-restricted-imports': ['error', {
75+
paths: [{
76+
name: '@objectstack/spec/ui',
77+
importNames: ['FormField', 'FormFieldSchema'],
78+
message:
79+
'This is the spec form-VIEW vocabulary (field = object-field reference), and its type erases to ' +
80+
'`any` (objectstack#4171) — importing it silently deletes type safety. The runtime form-field ' +
81+
'contract is `FormField`/`FormFieldSchema` from @object-ui/types; the two layers meet only in ' +
82+
'`normalizeSectionField` (@object-ui/plugin-form). See objectui#3090.',
83+
}],
84+
}],
6685
},
6786
}, {
6887
// objectui#3010/#3021 ratchet — a module loaded inside beforeAll/beforeEach

packages/components/src/renderers/form/form.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,7 +1118,9 @@ ComponentRegistry.register('form',
11181118
// ObjectForm) pass the field metadata through without hoisting
11191119
// its `widget` to the top-level form-config, which would
11201120
// otherwise degrade a picker field to its raw `type` input.
1121-
const resolvedType = widget || (fieldProps as any).field?.widget || type;
1121+
// (`.field` is the resolved metadata OBJECT — declared on FormField
1122+
// since #3090, never the spec string; see types/form.ts)
1123+
const resolvedType = widget || fieldProps.field?.widget || type;
11221124

11231125
// Cascading / role-gated option lists (#2284). For option fields,
11241126
// narrow the set by each option's `visibleWhen` (evaluated against
@@ -1251,9 +1253,11 @@ ComponentRegistry.register('form',
12511253
{/* Render the actual field component based on resolved type */}
12521254
{renderFieldComponent(resolvedType, {
12531255
...fieldProps,
1254-
// specialized fields needs raw metadata, but we should traverse down if it exists
1255-
// field is the field configuration loop variable
1256-
field: (field as any).field || field,
1256+
// Specialized fields need the raw metadata object. `.field`
1257+
// is the declared metadata slot (#3090 — never the spec
1258+
// string); fall back to the field config itself when no
1259+
// metadata was stashed (standalone forms).
1260+
field: field.field || field,
12571261
...formField,
12581262
inputType: fieldProps.inputType,
12591263
options: isOptionField ? effectiveOptions : fieldProps.options,

packages/core/src/actions/__tests__/actionKeys.pin.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { readFileSync } from 'node:fs';
1919
import { fileURLToPath } from 'node:url';
2020
import { dirname, join } from 'node:path';
2121
import ts from 'typescript';
22-
import * as SpecUI from '@objectstack/spec/ui';
22+
import { ActionSchema as SpecActionSchema } from '@objectstack/spec/ui';
2323
import {
2424
ACTION_DEF_KEYS,
2525
SPEC_ACTION_KEYS,
@@ -69,7 +69,7 @@ function specActionKeys(): string[] {
6969
}
7070
return null;
7171
};
72-
const keys = walk(SpecUI.ActionSchema);
72+
const keys = walk(SpecActionSchema);
7373
if (!keys) throw new Error('could not resolve @objectstack/spec/ui ActionSchema shape');
7474
return keys;
7575
}
@@ -99,7 +99,7 @@ describe('action key inventory (objectstack#4075 step 1)', () => {
9999
});
100100

101101
it('`execute` is still a live spec tombstone, so it must not count as known', () => {
102-
const parsed = SpecUI.ActionSchema.safeParse({
102+
const parsed = SpecActionSchema.safeParse({
103103
name: 'mark_done',
104104
label: 'Mark Done',
105105
type: 'script',

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,10 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
938938
// predicate must be merged onto the resolved field or it is silently
939939
// dropped — the form renderer evaluates it with the canonical engine.
940940
const sectionDefByName = new Map<string, any>(
941+
// AUTHORED section defs, pre-normalization — here `field` may
942+
// legitimately be the spec identity STRING (the cast is the boundary,
943+
// not a leak; on runtime FormFields the declared `field` slot is
944+
// always the metadata object, #3090).
941945
section.fields.map(f => [typeof f === 'string' ? f : ((f as any).field ?? f.name), f]),
942946
);
943947
const sectionFieldNames = Array.from(sectionDefByName.keys());

packages/plugin-form/src/sectionFields.spec-parity.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
*/
3131

3232
import { describe, it, expect } from 'vitest';
33+
// The drift guard is the ONE legitimate importer of the spec's FormFieldSchema
34+
// in this repo: it exists to enumerate the spec's key set. Everywhere else the
35+
// import is a layer violation — see the no-restricted-imports entry in
36+
// eslint.config.js (#3090).
37+
// eslint-disable-next-line no-restricted-imports
3338
import { FormFieldSchema as SpecFormFieldSchema } from '@objectstack/spec/ui';
3439
import { mapFieldTypeToFormType } from '@object-ui/fields';
3540
import { normalizeSectionField } from './sectionFields';

packages/plugin-form/src/sectionFields.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('normalizeSectionField', () => {
3030
expect(f.type).toBe(mapFieldTypeToFormType('text')); // merged from object schema
3131
expect(f.required).toBe(true); // spec override
3232
expect((f as any).colSpan).toBe(2); // spec override
33-
expect((f as any).field).toMatchObject({ type: 'text' }); // metadata object, not the string
33+
expect(f.field).toMatchObject({ type: 'text' }); // metadata object, not the string
3434
});
3535

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

165+
it('never emits a string `field` — the spec identity key ends at this boundary', () => {
166+
// On a runtime FormField the declared `field` slot holds the resolved
167+
// metadata OBJECT (or nothing) — never the spec's string reference. This
168+
// is the invariant that makes the same-key pun safe (#3090): the string
169+
// form exists only in AUTHORED defs, and this chokepoint is where it dies.
170+
const shapes: Array<string | Record<string, any>> = [
171+
'industry', // string shorthand
172+
{ field: 'industry', required: true }, // spec object
173+
{ field: 'ghost' }, // spec object, unknown to the schema
174+
{ name: 'custom', type: 'text' }, // already-runtime object
175+
];
176+
for (const def of shapes) {
177+
const out = normalizeSectionField(def as any, ctx);
178+
expect(typeof out.field, `string field leaked for ${JSON.stringify(def)}`).not.toBe('string');
179+
}
180+
});
181+
165182
it('warns once when an entry mixes both vocabularies, and the spec key wins', () => {
166183
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
167184
try {

packages/types/src/__tests__/spec-derived-unions.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,14 @@ import {
6464
WindowFunction as SpecWindowFunction,
6565
} from '@objectstack/spec/data';
6666
import type { JoinNode as SpecJoinNode } from '@objectstack/spec/data';
67+
// The objectstack#4171 inverted pin must import the banned name to probe its
68+
// any-ness — this guard is a sanctioned importer (#3090 tripwire).
69+
/* eslint-disable no-restricted-imports -- reported at the specifier line, out of -next-line reach */
6770
import type {
6871
NavigationItem as SpecNavigationItem,
6972
FormField as SpecFormField,
7073
} from '@objectstack/spec/ui';
74+
/* eslint-enable no-restricted-imports */
7175
import type { BreakpointName } from '../mobile';
7276
import type { ExportJobStatus, ImportJobStatus, ImportWriteMode, ValidationError } from '../data';
7377
import type { JoinStrategy, WindowFunction } from '../data-protocol';

packages/types/src/__tests__/spec-ui-schema-reexports.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import { describe, it, expect } from 'vitest';
1717
import { readFileSync } from 'node:fs';
1818
import { fileURLToPath } from 'node:url';
1919
import { dirname, join } from 'node:path';
20+
// This re-export parity guard walks the WHOLE spec/ui surface — the namespace
21+
// import is its instrument, a sanctioned importer (#3090 tripwire).
22+
// eslint-disable-next-line no-restricted-imports
2023
import * as SpecUI from '@objectstack/spec/ui';
2124
import * as Types from '../index';
2225

packages/types/src/form.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,23 @@ export interface FormField {
10011001
* @example "record.status == 'sent'"
10021002
*/
10031003
requiredWhen?: string | { dialect?: string; source: string };
1004+
/**
1005+
* The resolved object-field metadata **object** (typically a
1006+
* {@link FieldMetadata} / server-served field definition), stashed by the
1007+
* object-bound form paths so widgets can read `precision`, `currency`,
1008+
* `reference_to`, `depends_on`, … It feeds the field-widget `field` prop.
1009+
*
1010+
* ⚠️ Same key, different layer: in the SPEC form-view vocabulary `field` is
1011+
* a **string** (the referenced object-field name). That authored shape ends
1012+
* at the `normalizeSectionField` chokepoint in `@object-ui/plugin-form` —
1013+
* on a runtime FormField this slot is never a string, and its tripwire test
1014+
* pins that. Declared (rather than ridden through the index signature) so
1015+
* assigning a string here is a compile error instead of a latent pun
1016+
* (#3090). Typed loosely because the stash's source is the server-served
1017+
* object schema, whose key set is wider than the designer-oriented
1018+
* {@link FieldMetadata} union.
1019+
*/
1020+
field?: Record<string, any>;
10041021
/**
10051022
* Additional field-specific props
10061023
*/

0 commit comments

Comments
 (0)