Skip to content

Commit 6e47e16

Browse files
refactor(spec): share the form object-name resolver with lint (#25979)
_Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbTHQmGisuNMkvo5hbWyoWo5)_ Fixes #25756 Clause-②: yes (widening) Metadata-protocol: none ## What The rung order that resolves the object an open anonymous form submits into (the form's `data.object`, else the view's `list.data.object`, `form.data.object`, `object`) was typed twice: as `anonymousFormObjectName` in `@objectstack/metadata-core` and as `candidateObjectName` in the lint rule `validate-public-form-file-fields`. It now lives once, in `@objectstack/spec/ui` beside `anonymousFormIntakeCandidates`. - `packages/spec/src/ui/anonymous-form-intake.ts`: declares and exports `anonymousFormObjectName`; the docblock no longer says it stays in metadata-core. - `packages/metadata-core/src/anonymous-form-intake.ts`: re-exports the spec function. The consumers (rest `form-routes.ts`, `rest-server.ts`, metadata-protocol `runtime-authoring-gate.ts`) import from metadata-core unchanged. - `packages/lint/src/validate-public-form-file-fields.ts`: imports it from `@objectstack/spec/ui`; `candidateObjectName` is gone. - Pins: the metadata-core identity test now also holds `anonymousFormObjectName` to be the spec binding itself (module and barrel); a lint test pins that the rule imports it from `@objectstack/spec/ui` and keeps no retyped copy. - Generated: `api-surface/ui.json` and `export-origins/ui.json` (one added export); one changeset. ## Behaviour effect (stated, as ordered) Lint's old helper went through `viewObjectName` (view-walk.ts), which also reads `objectName` on a raw input and skips non-string or empty values. The shared function is the doors' own raw read and does neither, so lint now judges exactly the object the doors resolve. A form that sets `objectName` (not a spec key) or an empty or non-string `object` no longer binds an object in this rule; the doors never honoured those either, so the rule no longer judges a form the doors cannot reach. No producer in the repo was found relying on the old reading. ## Measurements - `git grep candidateObjectName -- packages`: 0 hits after the change. - `git grep "v.list?.data?.object" -- packages` still hits lint-view-refs.ts:185, an unrelated candidate list (the card's own correction). - Landing point as expected; no other producer package found. ## Acceptance notes Gate and test readings are in the os-dev report comment on #25756. --- _Generated by [Claude Code](https://claude.ai/code/session_01NRNx6Ah37J9r3zaMuuC4fs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e3e936e commit 6e47e16

8 files changed

Lines changed: 53 additions & 34 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/metadata-core': patch
4+
'@objectstack/lint': patch
5+
---
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
`@objectstack/spec/ui` now exports `anonymousFormObjectName`, the object an open anonymous form candidate submits into (the form's `data.object`, else the view's `list.data.object`, `form.data.object`, `object`). `@objectstack/metadata-core` re-exports the same function, and the public-form file-field lint rule in `@objectstack/lint` resolves the object through it instead of a retyped copy, so the doors and the lint cannot disagree.

‎packages/lint/src/validate-public-form-file-fields.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
// `view` write (`runRuntimeAuthoringRules`, the function the metadata write
88
// path calls) resolving the field's type against its `objects` context.
99

10+
import { readFileSync } from 'node:fs';
11+
1012
import { describe, expect, it } from 'vitest';
1113

1214
import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data';
@@ -163,3 +165,13 @@ describe('the runtime publish gate on a view write', () => {
163165
expect(errors.filter((e) => e.rule === PUBLIC_FORM_FILE_FIELD)).toEqual([]);
164166
});
165167
});
168+
169+
describe('the object a candidate submits into is the doors\' own function', () => {
170+
const source = readFileSync(new URL('./validate-public-form-file-fields.ts', import.meta.url), 'utf8');
171+
172+
it('imports anonymousFormObjectName from @objectstack/spec/ui and keeps no retyped rung order', () => {
173+
expect(source).toMatch(/anonymousFormObjectName,[\s\S]*?\} from '@objectstack\/spec\/ui'/);
174+
expect(source).not.toContain('candidateObjectName');
175+
expect(source).not.toContain('viewObjectName');
176+
});
177+
});

‎packages/lint/src/validate-public-form-file-fields.ts‎

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,14 @@
4242
*/
4343

4444
import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data';
45-
import { anonymousFormIntakeCandidates, type AnonymousFormIntakeCandidate } from '@objectstack/spec/ui';
45+
import {
46+
anonymousFormIntakeCandidates,
47+
anonymousFormObjectName,
48+
type AnonymousFormIntakeCandidate,
49+
} from '@objectstack/spec/ui';
4650

4751
import { collectionEntries } from './collection-entries.js';
4852
import { recordsOf } from './object-graph.js';
49-
import { viewObjectName } from './view-walk.js';
5053

5154
export const PUBLIC_FORM_FILE_FIELD = 'public-form-file-field';
5255

@@ -98,21 +101,6 @@ function candidateSlot(view: AnyRec, candidate: AnonymousFormIntakeCandidate): s
98101
return 'config';
99102
}
100103

101-
/**
102-
* The object an open candidate submits into, in the order the anonymous doors
103-
* resolve it (`anonymousFormObjectName`, `@objectstack/metadata-core`): the
104-
* form's own binding, then the view's list block, its default form, and the
105-
* view itself.
106-
*/
107-
function candidateObjectName(view: AnyRec, form: AnyRec): string | undefined {
108-
return (
109-
viewObjectName(form) ??
110-
(isRec(view.list) ? viewObjectName(view.list) : undefined) ??
111-
(isRec(view.form) ? viewObjectName(view.form) : undefined) ??
112-
viewObjectName(view)
113-
);
114-
}
115-
116104
/** A section field entry is a bare field name or `{ field, … }`. */
117105
function fieldNameOf(entry: unknown): string | undefined {
118106
if (typeof entry === 'string') return strName(entry);
@@ -127,7 +115,7 @@ export function validatePublicFormFileFields(stack: AnyRec): PublicFormFileField
127115
for (const { rec: view, path: viewPath } of collectionEntries(stack.views, 'views')) {
128116
const viewName = strName(view.name) ?? strName(view.object) ?? viewPath;
129117
for (const candidate of anonymousFormIntakeCandidates(view)) {
130-
const objectName = candidateObjectName(view, candidate.form);
118+
const objectName = anonymousFormObjectName(view, candidate.form);
131119
const types = objectName ? fieldTypes.get(objectName) : undefined;
132120
if (!types) continue;
133121
const slot = candidateSlot(view, candidate);

‎packages/metadata-core/src/anonymous-form-intake.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,12 +322,13 @@ describe('anonymousFormIntakeWithdrawnIn — an explicit withdrawal of the same
322322
// The candidates half is declared in `@objectstack/spec/ui` and re-exported by
323323
// this package. "One copy" is checkable only as IDENTITY: a wrapper or a copy
324324
// answers the same today and drifts tomorrow, while the same binding cannot.
325-
describe('the candidates half is the spec binding itself, re-exported (one copy, not a copy)', () => {
325+
describe('the candidates and object-name halves are the spec bindings themselves, re-exported (one copy, not a copy)', () => {
326326
const NAMES = [
327327
'publicFormSlug',
328328
'anonymousFormIntakeSlug',
329329
'anonymousFormIntakeCandidates',
330330
'anonymousFormIntakeSlugs',
331+
'anonymousFormObjectName',
331332
] as const;
332333

333334
it.each(NAMES)('%s: this module and the package barrel export the @objectstack/spec/ui function', (name) => {

‎packages/metadata-core/src/anonymous-form-intake.ts‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
* `allowAnonymous === true` and a non-empty `publicLink` naming the slug) and
1515
* the three shapes a view carries a form in — is declared in
1616
* `@objectstack/spec/ui` (`anonymous-form-intake.ts`, beside the
17-
* `SharingConfigSchema` it reads), whose docblock is the authority on it. This
17+
* `SharingConfigSchema` it reads), whose docblock is the authority on it. So is
18+
* the object-name half, `anonymousFormObjectName` (the object a candidate
19+
* submits into), which the authoring lint reads as well. This
1820
* module re-exports those bindings unchanged, so a console that imports them
1921
* from the spec reads the same rule the doors serve. Clearing either switch
2022
* withdraws the form from every anonymous door.
@@ -44,7 +46,8 @@ import { applyInjectedSystemColumns } from './injected-system-columns.js';
4446
import { resolveRecordWallOrganizationField } from './record-organization.js';
4547

4648
// The candidates half of the rule — which form candidates a `view` body opens
47-
// to anonymous intake — lives in `@objectstack/spec/ui` beside the
49+
// to anonymous intake — and the object-name half (`anonymousFormObjectName`,
50+
// the object a candidate submits into) live in `@objectstack/spec/ui` beside the
4851
// `SharingConfigSchema` it reads, so a console can import the same rule the
4952
// doors serve. Re-exported here as the SAME bindings (never a wrapper or a
5053
// copy), so every server caller keeps importing it from this package and the
@@ -53,17 +56,11 @@ export {
5356
anonymousFormIntakeCandidates,
5457
anonymousFormIntakeSlug,
5558
anonymousFormIntakeSlugs,
59+
anonymousFormObjectName,
5660
publicFormSlug,
5761
} from '@objectstack/spec/ui';
5862
export type { AnonymousFormIntakeCandidate } from '@objectstack/spec/ui';
5963

60-
/** The object an open form candidate submits into: the form's own `data.object`, else the view's. */
61-
export function anonymousFormObjectName(view: unknown, form: unknown): string | undefined {
62-
const v = (view && typeof view === 'object' ? view : {}) as Record<string, any>;
63-
const f = (form && typeof form === 'object' ? form : {}) as Record<string, any>;
64-
return f.data?.object ?? v.list?.data?.object ?? v.form?.data?.object ?? v.object;
65-
}
66-
6764
/**
6865
* Where a candidate's `sharing` sits in the `view` body — `form.sharing`,
6966
* `formViews.KEY.sharing` or `config.sharing`. Item-relative: the admin read

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@
507507
"anonymousFormIntakeCandidates (function)",
508508
"anonymousFormIntakeSlug (function)",
509509
"anonymousFormIntakeSlugs (function)",
510+
"anonymousFormObjectName (function)",
510511
"appForm (const)",
511512
"bulkConfirmTemplateRoots (function)",
512513
"chartAggregateCategoryKey (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@
491491
"anonymousFormIntakeCandidates": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates (function)",
492492
"anonymousFormIntakeSlug": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlug (function)",
493493
"anonymousFormIntakeSlugs": "src/ui/anonymous-form-intake.ts#anonymousFormIntakeSlugs (function)",
494+
"anonymousFormObjectName": "src/ui/anonymous-form-intake.ts#anonymousFormObjectName (function)",
494495
"appForm": "src/ui/app.form.ts#appForm (const)",
495496
"bulkConfirmTemplateRoots": "src/ui/bulk-action.zod.ts#bulkConfirmTemplateRoots (function)",
496497
"chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)",

‎packages/spec/src/ui/anonymous-form-intake.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,10 @@
4444
* (`anonymousFormIntakeWithdrawnIn`, a kill switch that layering may only
4545
* narrow, never re-open), and whether the deployment's tenancy posture lets an
4646
* open form take an anonymous submission (`anonymousFormIntakeUnavailability`).
47-
* Kept beside them is the object a candidate submits into
48-
* (`anonymousFormObjectName`): a pure read of the form's `data.object` and the
49-
* view's `list.data.object`, `form.data.object` and `object`, which reads no
50-
* server state and stays there because that is where this export's surface
51-
* was drawn. An open candidate here is therefore what the view body itself
52-
* declares, before any other layer or the posture is consulted.
47+
* An open candidate here is what the view body itself declares, before any
48+
* other layer or the posture is consulted. The object a candidate submits into
49+
* (`anonymousFormObjectName`) reads no server state, so it is declared here
50+
* too, and `@objectstack/metadata-core` re-exports it.
5351
*
5452
* Pure functions with no imports and no module-load work: this module links no
5553
* schema, so a browser bundle that reaches it pays for these functions alone.
@@ -80,6 +78,17 @@ export function anonymousFormIntakeSlug(sharing: unknown): string | null {
8078
return publicFormSlug(s.publicLink);
8179
}
8280

81+
/**
82+
* The object an open form candidate submits into: the form's own `data.object`,
83+
* else the view's `list.data.object`, `form.data.object` and `object`. The one
84+
* rung order both anonymous doors and the authoring lint resolve by.
85+
*/
86+
export function anonymousFormObjectName(view: unknown, form: unknown): string | undefined {
87+
const v = (view && typeof view === 'object' ? view : {}) as Record<string, any>;
88+
const f = (form && typeof form === 'object' ? form : {}) as Record<string, any>;
89+
return f.data?.object ?? v.list?.data?.object ?? v.form?.data?.object ?? v.object;
90+
}
91+
8392
/** Every form candidate of a `view` body that is open to anonymous intake, in scan order. */
8493
export function anonymousFormIntakeCandidates(view: unknown): AnonymousFormIntakeCandidate[] {
8594
if (!view || typeof view !== 'object') return [];

0 commit comments

Comments
 (0)