diff --git a/.changeset/30608-remaining-field-indexes-extension-fields.md b/.changeset/30608-remaining-field-indexes-extension-fields.md new file mode 100644 index 00000000000..10a206782da --- /dev/null +++ b/.changeset/30608-remaining-field-indexes-extension-fields.md @@ -0,0 +1,27 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: the remaining field indexes (form layout, notify email-field recipient, public form file field, flow templates, page visualization bindings, searchable fields, autonumber formats) judge an object as the registry serves it, and an object that declares no field keeps the list-view rule's skip once an extension adds to it (#30608) + +Clause-②: yes (narrowing) — `os validate` / `os build` / `os lint` refuse shapes they accepted before, each a field an `objectExtensions` entry of the same artifact adds or re-declares, now judged on its served definition: a notify node's `recipients.emailField` naming an owner `email` field an extension re-declares as `text` is `notify-email-field-not-email` at `error`; a form open to anonymous intake whose section names an `image` / `file` field an extension adds, or an owner field an extension re-declares as one, is `public-form-file-field` at `error`; a `list` page whose `allowedVisualizations` leads with `gallery` over an owner `image` field an extension re-declares as `text` is `page/visualization-without-binding` at `error`; a `searchableFields` entry naming an owner text field an extension re-declares as `formula` is `searchable-field-unsearchable` at `error`; an autonumber field an extension adds whose format names a field the object lacks is `autonumber-references-unknown-field` at `error`, and an autonumber format naming an owner field declared `required: true` that an extension re-declares without it is `autonumber-references-optional-field` at `warning`; a flow template `{{ record.F.sub }}` through an owner text field an extension re-declares as a lookup is `flow-template-lookup-traversal` at `warning`. The widening half: a form section, a notify email-field recipient, a flow template hole, a gallery or calendar binding, a `searchableFields` entry (an object's own set, a list view's narrowing, a react page's ``) or an autonumber format naming a field an `objectExtensions` entry adds is no longer refused as unknown, and a list view over an object that declares no field is no longer refused once an extension adds to it. +Metadata-protocol: narrowed +Skills-docs: none — measured: no sentence in `skills/` or `content/docs/` states a declared-only reading of the moved rule ids + + + +**BREAKING** accept-set narrowing in `os validate` / `os build` / `os lint` (`@objectstack/lint`), for multi-package artifacts whose bodies add fields to another package's object through `objectExtensions`. + +- **Before.** Seven field indexes read an object's declared `fields` only: the form-layout rule (`form-field-unknown`), the notify email-field recipient rule, the public form file-field rule, the flow template rule, the page visualization binding rule, the searchable-fields index (`indexObjectSearchTargets`, behind both an object's or list view's `searchableFields` and a react page's ``) and the autonumber format lint. A field an `objectExtensions` entry adds was unknown to them although the runtime serves it, and a field an extension re-declares was judged on the owner's definition. The list-view rule, since it reads the served graph, judged an object that declares no field as having only the extension's fields. +- **Now.** Each reads the shared fold in `object-graph.ts` (`objectsAsServed`): the declared fields plus each field an `objectExtensions` entry adds, an extension's re-declared field replacing the owner's definition whole, as the registry's `mergeObjectDefinitions` does. A name no body declares or adds keeps the same refusal, rule id, severity and message. These findings move (union artifact and per-package leg alike): + - an extension-added field named by a form section, a notify email-field recipient (typed `email`), a flow template hole, a `searchableFields` entry or an autonumber format (declared `required: true`): its unknown-field verdict (`form-field-unknown` `warning`, `notify-email-field-unknown` `error`, `flow-template-unknown-field` `warning`, `searchable-field-unknown` `error`, `autonumber-references-unknown-field` `error`) → nothing; a `gallery` / `calendar` binding derives from it, so `page/visualization-without-binding` → nothing; + - the same field typed otherwise: a notify recipient typed `text` → `notify-email-field-not-email` `error` (was `notify-email-field-unknown` `error`); an autonumber format naming it not `required` → `autonumber-references-optional-field` `warning` (was `autonumber-references-unknown-field` `error`); on a public form, an `image` / `file` / `avatar` / `video` / `audio` field → `public-form-file-field` `error` (was unjudged); + - an owner field an extension re-declares: judged on the extension's type, `required`, or autonumber format (the rows the Clause-② line names), and the inverse re-declarations pass (an owner `file` field re-declared `text` on a public form, an owner lookup re-declared `text` in a template, an owner autonumber re-declared `text`); + - an autonumber field an extension adds is checked as an owner's is; + - a list view over an object that declares no field: `list-view-field-unknown` `error` → nothing, extended or not. + - a flow variable bound to a record whose name is a field an `objectExtensions` entry adds to the trigger object: the record-triggered run flattens that field to top level too, so the variable is an ambiguous root and its holes are no longer judged (`flow-template-unknown-field` `warning` → nothing), as for a variable named after a declared trigger field; + - a form section on an extended object whose owner declares `fields` in the legacy array shape: the owner's array fields are now known names, so `form-field-unknown` `warning` on them → nothing. +- **Not judged.** A field two packages write at the same deciding priority is a served name, but no rule judges its type, `required` or autonumber format: which definition wins is registration order (a gallery page over an object carrying one is not reported). An object that declares no field of its own stays unjudged by the notify, public form, page visualization, searchable and autonumber rules and by the list-view rule, extended or not (an introspected schema). The form-layout and flow template rules judged such an object against the empty set before and still do, an extension's own names now known. +- **Not changed.** Which extension entries the fold keeps, the runtime, the object-level reads (`external`, the injected anchors, an object's own list views and `searchableFields`), and the finding paths. A stack that names no extension field reads as before. A flow variable whose name no body declares or adds on the trigger object is judged as before. A form section on an object nothing extends reads only map-shaped `fields`, as before: its legacy array fields are still reported `form-field-unknown`. + +**What an author does.** Fix the extension's field definition (type an email recipient `email`, keep a file field off a public form, give a gallery an image field, keep a search column stored, mark an autonumber scope field `required: true`) or the reference that names it. diff --git a/packages/lint/src/lint-autonumber-formats.ts b/packages/lint/src/lint-autonumber-formats.ts index 5f56cbb17f0..e8589ab40eb 100644 --- a/packages/lint/src/lint-autonumber-formats.ts +++ b/packages/lint/src/lint-autonumber-formats.ts @@ -27,10 +27,26 @@ * a `format` shorthand the runtime then rendered and threw on. A field that * declares neither key is checked against the contract default, which is what * it mints with. + * + * [#30608] Each object is read as SERVED (`objectsAsServed`, the package's one + * fold): the engine mints against the registry's merged object, so a format + * may interpolate a field an `objectExtensions` entry of the same artifact + * adds, an autonumber field an extension adds is minted and so checked, and an + * owner's field an extension re-declares is read on the extension's + * definition, which replaces the owner's whole (the registry's + * `mergeObjectDefinitions`): an autonumber re-declared as `text` mints + * nothing, and a referenced field re-declared without `required` is optional. + * Whether a field is an autonumber, its format and whether it is required are + * its definition, so a field two packages tie on is never walked as an + * autonumber, and a format naming one is reported neither unknown (the name is + * served) nor optional (which definition wins is registration order). An + * object that declares no field (an introspected schema) is skipped, extended + * or not: its other columns are not visible here, so a format naming one would + * read as unknown. */ import { parseAutonumberFormat, referencedFields, resolveAutonumberFormat } from '@objectstack/spec/data'; -import { recordsOf } from './object-graph.js'; +import { objectsAsServed, objectsWithoutTiedFields, recordsOf } from './object-graph.js'; export interface AutonumberLintFinding { where: string; @@ -53,14 +69,22 @@ export const AUTONUMBER_LITERAL_TOKEN = 'autonumber-unrecognized-token'; */ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] { const findings: AutonumberLintFinding[] = []; - for (const obj of recordsOf(stack.objects)) { + const declared = recordsOf(stack.objects); + const servedFold = objectsAsServed(stack); + const served = objectsWithoutTiedFields(servedFold); + declared.forEach((obj, i) => { const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)'; - const fields = recordsOf(obj.fields); - // name → required?, for schema-aware reference checks. - const fieldMeta = new Map(); + // [#30608] Skipped when it declares no field, extended or not (module note). + if (!recordsOf(obj.fields).some((f) => typeof f.name === 'string')) return; + const fields = recordsOf(served[i].fields); + const tiedKeys = typeof obj.name === 'string' ? servedFold.tied.get(obj.name) : undefined; + // name → required?, for schema-aware reference checks. `null` is a field + // two packages tie on: served, of no stated definition. + const fieldMeta = new Map(); for (const f of fields) { if (typeof f.name === 'string') fieldMeta.set(f.name, { required: f.required === true }); } + for (const key of tiedKeys ?? []) fieldMeta.set(key, null); for (const f of fields) { if (f.type !== 'autonumber') continue; @@ -106,6 +130,7 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] { continue; } const meta = fieldMeta.get(ref); + if (meta === null) continue; if (!meta) { findings.push({ where, @@ -125,6 +150,6 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] { } } } - } + }); return findings; } diff --git a/packages/lint/src/object-graph.test.ts b/packages/lint/src/object-graph.test.ts index 7ef614d204b..f8720dceb36 100644 --- a/packages/lint/src/object-graph.test.ts +++ b/packages/lint/src/object-graph.test.ts @@ -44,7 +44,21 @@ import { FLOW_UPDATE_READONLY_FIELD, FLOW_UPDATE_READONLY_WHEN_FIELD, validateRe import { HOOK_API_UPDATE_READONLY_FIELD, HOOK_API_UPDATE_READONLY_WHEN_FIELD, validateReadonlyHookWrites } from './validate-readonly-hook-writes.js'; import { SORT_FIELD_UNKNOWN, SORT_FIELD_UNSORTABLE, validateSortableFields } from './validate-sortable-fields.js'; import { ACTION_API_UPDATE_READONLY_WHEN_FIELD, validateReadonlyActionWrites } from './validate-readonly-action-writes.js'; -import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js'; +import { SEARCHABLE_FIELD_UNKNOWN, SEARCHABLE_FIELD_UNSEARCHABLE, validateSearchableFields } from './validate-searchable-fields.js'; +import { FORM_FIELD_UNKNOWN, validateFormLayout } from './validate-form-layout.js'; +import { + NOTIFY_EMAIL_FIELD_NOT_EMAIL, + NOTIFY_EMAIL_FIELD_UNKNOWN, + validateNotifyEmailFieldRecipients, +} from './validate-notify-email-field-recipients.js'; +import { PUBLIC_FORM_FILE_FIELD, validatePublicFormFileFields } from './validate-public-form-file-fields.js'; +import { + FLOW_TEMPLATE_LOOKUP_TRAVERSAL, + FLOW_TEMPLATE_UNKNOWN_FIELD, + validateFlowTemplatePaths, +} from './validate-flow-template-paths.js'; +import { PAGE_VISUALIZATION_WITHOUT_BINDING, validatePageVisualizationBindings } from './validate-page-visualization-bindings.js'; +import { AUTONUMBER_OPTIONAL_FIELD, AUTONUMBER_UNKNOWN_FIELD, lintAutonumberFormats } from './lint-autonumber-formats.js'; // [#16340] Read back the registry's OWN definition table to assert the graph // derives its injected types rather than carrying a second copy of them. import { injectedSystemColumnDefs } from '@objectstack/spec/data'; @@ -1045,17 +1059,21 @@ describe('[#30471] the field-name indexes read the objects as served', () => { }); }); - // `indexObjectSearchTargets` stays on the declared objects (its type - // question is the searchable-fields rule's, not moved here), so a react - // page's `searchableFields` naming an extension field is still refused. - it('validate-react-page-props: searchableFields still reads the declared objects', () => { + // [#30608] A react page's `searchableFields` reads the served search index + // (`indexServedSearchTargets`), as the metadata rule does: the override it + // sends reaches the ingress gate, which resolves it against the registry's + // merged object. So a field an extension adds passes, and one nothing adds + // is still refused. + it('validate-react-page-props: searchableFields reads the objects as served', () => { const search = (field: string) => ({ pages: [{ name: 'p', kind: 'react', source: `function Page(){ return ; }`, }], }); - expect(ids(validateReactPageProps, artifact(search('region')))).toEqual([SEARCHABLE_FIELD_UNKNOWN]); + expect(ids(validateReactPageProps, artifact(search('region')))).toEqual([]); + expect(ids(validateReactPageProps, artifact(search('region'), []))).toEqual([SEARCHABLE_FIELD_UNKNOWN]); + expect(ids(validateReactPageProps, artifact(search('nowhere')))).toEqual([SEARCHABLE_FIELD_UNKNOWN]); expect(ids(validateReactPageProps, artifact(search('name')))).toEqual([]); }); @@ -1163,3 +1181,262 @@ describe('[#30471] the field-name indexes read the objects as served', () => { }); }); }); + +// [#30608] The seven field indexes #30471 left on the declared objects, and the +// list-view rule's declares-nothing skip, read the same fold. Each site: an +// extension-added field is no longer refused where the runtime serves it; an +// extension's re-declared field is judged on the extension's definition; a +// field two packages tie on is a served name judged on no definition; an +// object that declares no field stays unjudged where `main` skipped it, +// extended or not. Every stack runs on both CLI legs (the union artifact, then +// the per-package body), each extension list its own package. +describe('[#30608] the remaining field indexes read the objects as served', () => { + type Rec = Record; + type Finding = { rule: string; severity?: unknown; where?: unknown; path?: unknown; message?: unknown }; + type Run = (stack: Rec) => ReadonlyArray; + const deal = (fields: Rec = {}, more: Rec = {}): Rec => + ({ name: 'xs_deal', label: 'Deal', fields: { name: { type: 'text' }, ...fields }, ...more }); + const bare = { name: 'xs_deal', label: 'Deal', fields: {} }; + const account = { name: 'xs_account', label: 'Account', fields: { name: { type: 'text' } } }; + const ext = (fields: Rec) => [{ extend: 'xs_deal', fields }]; + const onLegs = (extra: Rec, packages: unknown[][], objects: unknown[] = [deal(), account]): Rec[] => { + const mods = packages.map((objectExtensions, i) => ({ manifest: { id: `mod${i}`, objectExtensions } })); + const body = { id: 'app', objects, ...extra }; + return [ + { objects, packages: [{ manifest: { id: 'app', objects } }, ...mods], ...extra }, + { ...body, manifest: body, packages: [{ manifest: body }, ...mods] }, + ]; + }; + const verdicts = (run: Run, stack: Rec) => run(stack).map((f) => [f.rule, f.severity]); + + const form = (field: string, sharing?: Rec): Rec => ({ + views: [{ + name: 'vf', + form: { + type: 'simple', data: { provider: 'object', object: 'xs_deal' }, sections: [{ name: 's', fields: ['name', field] }], + ...(sharing ? { sharing } : {}), + }, + }], + }); + const publicForm = (field: string) => form(field, { enabled: true, allowAnonymous: true, publicLink: 'intake' }); + const notify = (field: string): Rec => ({ + flows: [{ + name: 'fl', + nodes: [ + { id: 'start', type: 'start', config: { triggerType: 'record-created', objectName: 'xs_deal' } }, + { id: 'n', type: 'notify', config: { recipients: { emailField: field }, title: 'T', message: 'M' } }, + ], + }], + }); + const template = (hole: string): Rec => ({ + flows: [{ + name: 'ft', + nodes: [ + { id: 'start', type: 'start', config: { triggerType: 'record-created', objectName: 'xs_deal' } }, + { id: 'n', type: 'notify', config: { recipients: { users: ['u'] }, title: 'T', message: `Hi {{ ${hole} }}` } }, + ], + }], + }); + const gallery: Rec = { + pages: [{ name: 'pg', type: 'list', object: 'xs_deal', interfaceConfig: { source: 'xs_deal', appearance: { allowedVisualizations: ['gallery'] } } }], + }; + const listSearch = (field: string): Rec => ({ + views: [{ name: 'vs', objectName: 'xs_deal', list: { type: 'grid', columns: ['name'], searchableFields: ['name', field] } }], + }); + const reactSearch = (field: string): Rec => ({ + pages: [{ + name: 'p', kind: 'react', + source: `function Page(){ return ; }`, + }], + }); + const autonumber = (format: string, more: Rec = {}) => [deal({ ref: { type: 'autonumber', autonumberFormat: format }, ...more }), account]; + const canonicalSearch = (field: string, more: Rec = {}) => [deal(more, { searchableFields: ['name', field] }), account]; + const autonumberRun: Run = (stack) => lintAutonumberFormats(stack); + + // rule · its unknown-field id · the stack naming FIELD · the objects · the extension fields adding FIELD + const existence: Array<[string, string, Run, (field: string) => Rec, (field: string) => unknown[] | undefined, (field: string) => Rec]> = [ + ['validate-form-layout', FORM_FIELD_UNKNOWN, validateFormLayout, form, () => undefined, (f) => ({ [f]: { type: 'text' } })], + ['validate-notify-email-field-recipients', NOTIFY_EMAIL_FIELD_UNKNOWN, validateNotifyEmailFieldRecipients, notify, () => undefined, + (f) => ({ [f]: { type: 'email' } })], + ['validate-flow-template-paths', FLOW_TEMPLATE_UNKNOWN_FIELD, validateFlowTemplatePaths, (f) => template(`record.${f}`), () => undefined, + (f) => ({ [f]: { type: 'text' } })], + ['validate-searchable-fields (an object\'s own set)', SEARCHABLE_FIELD_UNKNOWN, validateSearchableFields, () => ({}), (f) => canonicalSearch(f), + (f) => ({ [f]: { type: 'text' } })], + ['validate-searchable-fields (a list view\'s narrowing)', SEARCHABLE_FIELD_UNKNOWN, validateSearchableFields, listSearch, () => undefined, + (f) => ({ [f]: { type: 'text', searchable: true } })], + ['validate-react-page-props (searchableFields)', SEARCHABLE_FIELD_UNKNOWN, validateReactPageProps, reactSearch, () => undefined, + (f) => ({ [f]: { type: 'text', searchable: true } })], + ['lint-autonumber-formats', AUTONUMBER_UNKNOWN_FIELD, autonumberRun, () => ({}), (f) => autonumber(`{${f}}{000}`), + (f) => ({ [f]: { type: 'text', required: true } })], + ]; + + it.each(existence)('%s: an extension-added field passes; with no extension, or a field nothing adds, it is refused', (_rule, id, run, naming, objects, adding) => { + for (const s of onLegs(naming('region'), [ext(adding('region'))], objects('region'))) expect(verdicts(run, s)).toEqual([]); + for (const s of onLegs(naming('region'), [[]], objects('region'))) expect(run(s).map((f) => f.rule)).toEqual([id]); + for (const s of onLegs(naming('nowhere'), [ext(adding('region'))], objects('nowhere'))) expect(run(s).map((f) => f.rule)).toEqual([id]); + }); + + it.each(existence)('%s: a field nothing adds is refused byte-for-byte as on the declared objects alone', (_rule, id, run, naming, objects, adding) => { + const refusal = (stack: Rec) => run(stack) + .filter((f) => f.rule === id) + .map(({ severity, where, path, message }) => ({ severity, where, path, message })); + const [servedUnion, servedPerPackage] = onLegs(naming('zq_absent'), [ext(adding('region'))], objects('zq_absent')); + const [declaredUnion, declaredPerPackage] = onLegs(naming('zq_absent'), [[]], objects('zq_absent')); + expect(refusal(servedUnion)).toHaveLength(1); + expect(refusal(servedUnion)).toEqual(refusal(declaredUnion)); + expect(refusal(servedPerPackage)).toEqual(refusal(declaredPerPackage)); + }); + + // Every verdict this change adds or moves on a definition: the stack, the + // objects, the extension fields; what `main` answered (the declared objects + // alone, the same stack with no extension where the field is the owner's) + // and what the served object answers now. + const moved: Array<[string, Run, Rec, unknown[], Rec, unknown[][], unknown[][]]> = [ + ['notify: an added field typed text is not-email (was unknown)', validateNotifyEmailFieldRecipients, notify('contact_email'), + [deal(), account], { contact_email: { type: 'text' } }, + [[NOTIFY_EMAIL_FIELD_UNKNOWN, 'error']], [[NOTIFY_EMAIL_FIELD_NOT_EMAIL, 'error']]], + ['notify: an owner email field re-declared as text', validateNotifyEmailFieldRecipients, notify('contact_email'), + [deal({ contact_email: { type: 'email' } }), account], { contact_email: { type: 'text' } }, + [], [[NOTIFY_EMAIL_FIELD_NOT_EMAIL, 'error']]], + ['public form: an added image field', validatePublicFormFileFields, publicForm('photo'), + [deal(), account], { photo: { type: 'image' } }, + [], [[PUBLIC_FORM_FILE_FIELD, 'error']]], + ['public form: an owner text field re-declared as file', validatePublicFormFileFields, publicForm('doc'), + [deal({ doc: { type: 'text' } }), account], { doc: { type: 'file' } }, + [], [[PUBLIC_FORM_FILE_FIELD, 'error']]], + ['public form: an owner file field re-declared as text', validatePublicFormFileFields, publicForm('doc'), + [deal({ doc: { type: 'file' } }), account], { doc: { type: 'text' } }, + [[PUBLIC_FORM_FILE_FIELD, 'error']], []], + ['flow template: an owner text field re-declared as a lookup is a traversal', validateFlowTemplatePaths, template('record.account.name'), + [deal({ account: { type: 'text' } }), account], { account: { type: 'lookup', reference: 'xs_account' } }, + [], [[FLOW_TEMPLATE_LOOKUP_TRAVERSAL, 'warning']]], + ['flow template: an owner lookup re-declared as text is not', validateFlowTemplatePaths, template('record.account.name'), + [deal({ account: { type: 'lookup', reference: 'xs_account' } }), account], { account: { type: 'text' } }, + [[FLOW_TEMPLATE_LOOKUP_TRAVERSAL, 'warning']], []], + ['gallery page: an added image field binds', validatePageVisualizationBindings, gallery, + [deal(), account], { photo: { type: 'image' } }, + [[PAGE_VISUALIZATION_WITHOUT_BINDING, 'error']], []], + ['gallery page: an owner image field re-declared as text binds nothing', validatePageVisualizationBindings, gallery, + [deal({ photo: { type: 'image' } }), account], { photo: { type: 'text' } }, + [], [[PAGE_VISUALIZATION_WITHOUT_BINDING, 'error']]], + ['searchable: an owner text field re-declared as formula', validateSearchableFields, {}, + canonicalSearch('title', { title: { type: 'text' } }), { title: { type: 'formula', expression: 'record.name' } }, + [], [[SEARCHABLE_FIELD_UNSEARCHABLE, 'error']]], + ['autonumber: a referenced field an extension adds optional (was unknown)', autonumberRun, {}, + autonumber('{region}{000}'), { region: { type: 'text' } }, + [[AUTONUMBER_UNKNOWN_FIELD, 'error']], [[AUTONUMBER_OPTIONAL_FIELD, 'warning']]], + ['autonumber: a required owner field re-declared optional', autonumberRun, {}, + autonumber('{plan}{000}', { plan: { type: 'text', required: true } }), { plan: { type: 'text' } }, + [], [[AUTONUMBER_OPTIONAL_FIELD, 'warning']]], + ['autonumber: an autonumber field an extension adds is checked', autonumberRun, {}, + [deal(), account], { ref2: { type: 'autonumber', autonumberFormat: '{nowhere}{000}' } }, + [], [[AUTONUMBER_UNKNOWN_FIELD, 'error']]], + ['autonumber: an owner autonumber re-declared as text mints nothing', autonumberRun, {}, + autonumber('{nowhere}{000}'), { ref: { type: 'text' } }, + [[AUTONUMBER_UNKNOWN_FIELD, 'error']], []], + ]; + + it.each(moved)('%s', (_case, run, stack, objects, fields, before, after) => { + for (const s of onLegs(stack, [[]], objects)) expect(verdicts(run, s)).toEqual(before); + for (const s of onLegs(stack, [ext(fields)], objects)) expect(verdicts(run, s)).toEqual(after); + }); + + // A field two packages write at the deciding priority: its name is served, + // its definition is registration order. Each definition question leaves it + // unjudged in both orders; the definition that draws the verdict, written by + // one package alone, still draws it. + // rule · stack · objects · the drawing definition · the other · the id it draws + const tied: Array<[string, Run, Rec, unknown[], Rec, Rec, string]> = [ + ['notify', validateNotifyEmailFieldRecipients, notify('contact_email'), [deal(), account], + { contact_email: { type: 'text' } }, { contact_email: { type: 'email' } }, NOTIFY_EMAIL_FIELD_NOT_EMAIL], + ['public form', validatePublicFormFileFields, publicForm('photo'), [deal(), account], + { photo: { type: 'image' } }, { photo: { type: 'text' } }, PUBLIC_FORM_FILE_FIELD], + ['flow template', validateFlowTemplatePaths, template('record.account.name'), [deal(), account], + { account: { type: 'lookup', reference: 'xs_account' } }, { account: { type: 'text' } }, FLOW_TEMPLATE_LOOKUP_TRAVERSAL], + ['gallery page', validatePageVisualizationBindings, gallery, [deal(), account], + { photo: { type: 'text' } }, { photo: { type: 'image' } }, PAGE_VISUALIZATION_WITHOUT_BINDING], + ['searchable', validateSearchableFields, {}, canonicalSearch('title'), + { title: { type: 'formula', expression: 'record.name' } }, { title: { type: 'text' } }, SEARCHABLE_FIELD_UNSEARCHABLE], + ['autonumber', autonumberRun, {}, autonumber('{region}{000}'), + { region: { type: 'text' } }, { region: { type: 'text', required: true } }, AUTONUMBER_OPTIONAL_FIELD], + ]; + + it.each(tied)('%s: a field two packages tie on is unjudged', (_rule, run, stack, objects, drawing, other, id) => { + for (const [a, b] of [[drawing, other], [other, drawing]]) { + for (const s of onLegs(stack, [ext(a), ext(b)], objects)) expect(verdicts(run, s)).toEqual([]); + } + for (const s of onLegs(stack, [ext(drawing), ext({})], objects)) expect(run(s).map((f) => f.rule)).toEqual([id]); + }); + + // Every served field tied (the owner declares only `title`, two packages + // re-declare it): the names still come from the served object, so an entry + // no body declares is refused as on `main`, in both registration orders, + // and the tied name itself is a served name with nothing judged on it. + it('searchable: an object whose every served field is tied still refuses an unknown entry', () => { + const titled = (more: Rec = {}) => ({ name: 'xs_deal', label: 'Deal', fields: { title: { type: 'text' } }, ...more }); + const surfaces: Array<[string, (entry: string) => [Rec, unknown[]]]> = [ + ['object', (entry) => [{}, [titled({ searchableFields: [entry] }), account]]], + ['list view', (entry) => [{ + views: [{ name: 'vs', objectName: 'xs_deal', list: { type: 'grid', columns: ['title'], searchableFields: [entry] } }], + }, [titled(), account]]], + ]; + const a = { title: { type: 'text' } }; + const b = { title: { type: 'formula', expression: 'record.title' } }; + for (const [surface, build] of surfaces) { + const [unknown, unknownObjects] = build('nowhere'); + const [known, knownObjects] = build('title'); + for (const packages of [[ext(a), ext(b)], [ext(b), ext(a)]]) { + for (const s of onLegs(unknown, packages, unknownObjects)) { + expect(verdicts(validateSearchableFields, s), surface).toEqual([[SEARCHABLE_FIELD_UNKNOWN, 'error']]); + } + for (const s of onLegs(known, packages, knownObjects)) expect(verdicts(validateSearchableFields, s), surface).toEqual([]); + } + // Controls: unextended, the same refusal; one writer alone, judged on its definition. + for (const s of onLegs(unknown, [[]], unknownObjects)) { + expect(verdicts(validateSearchableFields, s), surface).toEqual([[SEARCHABLE_FIELD_UNKNOWN, 'error']]); + } + for (const s of onLegs(known, [ext(b), ext({})], knownObjects)) { + expect(validateSearchableFields(s).map((f) => f.rule), surface).toEqual([SEARCHABLE_FIELD_UNSEARCHABLE]); + } + } + }); + + // An object that declares no field (an introspected schema) is left + // unjudged where `main` skipped it: an extension adding to it does not make + // its other columns knowable, nor its added fields judgeable by these rules. + const unjudged: Array<[string, Run, Rec, Rec]> = [ + ['notify', validateNotifyEmailFieldRecipients, notify('remote_col'), { contact_email: { type: 'text' } }], + ['public form', validatePublicFormFileFields, publicForm('photo'), { photo: { type: 'image' } }], + ['gallery page', validatePageVisualizationBindings, gallery, { name: { type: 'text' } }], + ['searchable', validateSearchableFields, listSearch('remote_col'), { region: { type: 'text' } }], + ['autonumber', autonumberRun, {}, { ref2: { type: 'autonumber', autonumberFormat: '{remote_col}{000}' } }], + ['list-view columns', validateListViewFieldRefs, { + views: [{ name: 'v', objectName: 'xs_deal', list: { type: 'grid', columns: ['remote_col'] } }], + }, { region: { type: 'text' } }], + ]; + + it.each(unjudged)('%s: an object that declares no field is unjudged, extended or not', (_rule, run, stack, fields) => { + for (const packages of [[ext(fields)], [[]]]) { + for (const s of onLegs(stack, packages, [bare, account])) expect(verdicts(run, s)).toEqual([]); + } + }); + + // Item 2 of the card, as measured: the list-view rule on an object that + // declares no field, with and without the extension, and the control that a + // declared object's unknown column is still refused at the same path. + it('validate-list-view-field-refs: the declares-nothing skip holds once an extension adds to the object', () => { + const empty = { name: 'xs_empty', label: 'Empty', fields: {} }; + const view = (column: string) => ({ views: [{ name: 'v', objectName: 'xs_empty', list: { type: 'grid', columns: [column] } }] }); + const extendEmpty = [{ extend: 'xs_empty', fields: { region: { type: 'text' } } }]; + for (const packages of [[extendEmpty], [[]]]) { + for (const s of onLegs(view('name'), packages, [empty])) expect(validateListViewFieldRefs(s)).toEqual([]); + } + const titled = { name: 'xs_empty', label: 'Empty', fields: { title: { type: 'text' } } }; + for (const s of onLegs(view('nowhere'), [extendEmpty], [titled])) { + expect(validateListViewFieldRefs(s).map((f) => [f.rule, f.severity, f.path])).toEqual([ + [LIST_VIEW_FIELD_UNKNOWN, 'error', 'views[0].list.columns[0]'], + ]); + } + for (const s of onLegs(view('region'), [extendEmpty], [titled])) expect(validateListViewFieldRefs(s)).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-flow-template-paths.ts b/packages/lint/src/validate-flow-template-paths.ts index 88154bd85ff..c4ff18d7ce5 100644 --- a/packages/lint/src/validate-flow-template-paths.ts +++ b/packages/lint/src/validate-flow-template-paths.ts @@ -11,8 +11,10 @@ // exactly the failure #3426 reported: // // 1. `{{ record. }}` — the path head is neither a declared field nor a -// system column. Almost always a typo (`{{ record.full_naem }}`). The template -// engine resolves it to `undefined` -> '' with no warning. +// system column (declared includes every field an `objectExtensions` entry +// of the same artifact adds, which the registry serves, #30608). Almost +// always a typo (`{{ record.full_naem }}`). The template engine resolves +// it to `undefined` -> '' with no warning. // // 2. `{{ record.. }}` — a cross-object hop through a lookup / // master_detail / user / tree relation. The seeded flow record carries the @@ -109,7 +111,7 @@ import { unprovisionedAnchorFix, } from './system-fields.js'; import { walkFlowNodes, type WalkedFlowNode } from './flow-walk.js'; -import { recordsOf } from './object-graph.js'; +import { objectsAsServed, recordsOf } from './object-graph.js'; export type FlowTemplatePathSeverity = 'error' | 'warning'; @@ -162,7 +164,7 @@ const FILTER_VALUE_SLOT_NODE_TYPES: ReadonlySet = new Set([ 'delete_record', ]); -/** Build a `fieldName -> type` map for an object (declared fields only). */ +/** Build a `fieldName -> type` map for an object, from the fields it is handed. */ function fieldTypesOf(obj: AnyRec): Map { const types = new Map(); for (const f of recordsOf(obj.fields)) { @@ -493,18 +495,36 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin const flows = recordsOf(stack.flows); if (flows.length === 0) return findings; + // [#30608] Two reads of each object. `objectsByName` is the declared object: + // root resolution and the object-level provenance question + // (`unprovisionedInjectedColumnsFor`, `external` and the injected anchors) + // stay on it. `servedByName` is the same object as SERVED (`objectsAsServed`, + // the package's one fold; `served[i]` is the served form of `declared[i]`), + // which the field map below reads: the run's record is the merged object, so + // a hole naming a field an `objectExtensions` entry of the same artifact adds + // resolves, and a head an extension re-declares is judged on the extension's + // type (a lookup there is a traversal, a text there is not), as the + // registry's `mergeObjectDefinitions` replaces the owner's definition whole. const objectsByName = new Map(); - for (const obj of recordsOf(stack.objects)) { - if (typeof obj.name === 'string') objectsByName.set(obj.name, obj); - } + const servedByName = new Map(); + const { objects: served, tied } = objectsAsServed(stack); + recordsOf(stack.objects).forEach((obj, i) => { + if (typeof obj.name !== 'string') return; + objectsByName.set(obj.name, obj); + servedByName.set(obj.name, served[i]); + }); // One field map per object, not per root: a flow may resolve several roots // onto the same object (a trigger record plus a `get_record` re-read of it). + // [#30608] A field two packages tie on is a known head of no stated type: + // its name is served, and which definition wins is registration order, so + // the traversal question (a type question) is not asked of it. const fieldTypeCache = new Map>(); const fieldTypesFor = (objectName: string, obj: AnyRec): Map => { let types = fieldTypeCache.get(objectName); if (!types) { - types = fieldTypesOf(obj); + types = fieldTypesOf(servedByName.get(objectName) ?? obj); + for (const key of tied.get(objectName) ?? []) if (types.has(key)) types.set(key, ''); fieldTypeCache.set(objectName, types); } return types; diff --git a/packages/lint/src/validate-form-layout.ts b/packages/lint/src/validate-form-layout.ts index 92b65c1e07e..42be907b4c7 100644 --- a/packages/lint/src/validate-form-layout.ts +++ b/packages/lint/src/validate-form-layout.ts @@ -66,7 +66,7 @@ import { sectionGroupRefs, } from './object-field-groups.js'; import { formViewSites, viewObjectName } from './view-walk.js'; -import { recordsOf } from './object-graph.js'; +import { objectsAsServed, recordsOf } from './object-graph.js'; export const FORM_FIELD_UNKNOWN = 'form-field-unknown'; /** @@ -138,16 +138,32 @@ function fieldNameOf(entry: unknown): string | null { export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { const findings: FormLayoutFinding[] = []; - // object name → its field-name set, for reference checking. + // object name → its field-name set, for reference checking. [#30608] Over + // the objects as SERVED (`objectsAsServed`, the package's one fold), as the + // renderer reads the merged object: a section naming a field an + // `objectExtensions` entry of the same artifact adds renders, so it is not + // reported, and a field no body declares or extends still is. Existence + // never asks which definition wins, so a field two packages tie on is a + // known name. An object nothing extends keeps its declared read exactly + // (map-shaped `fields` only); `served[i]` is the served form of + // `declared[i]` (the fold's contract), and is a new record only for an + // extended object, whose served `fields` is the fold's list. An object that + // declares no field was judged against the empty set on `main` (every + // section field reported), so an extension adding to it only takes its own + // names out of that report: no verdict moves toward a refusal. const objectFields = new Map>(); - for (const obj of recordsOf(stack.objects)) { + const declared = recordsOf(stack.objects); + const served = objectsAsServed(stack).objects; + declared.forEach((obj, i) => { const name = typeof obj.name === 'string' ? obj.name : undefined; - if (!name) continue; - const fields = (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields)) - ? Object.keys(obj.fields as AnyRec) - : []; + if (!name) return; + const fields = served[i] !== obj + ? recordsOf(served[i].fields).map((f) => f.name).filter((n): n is string => typeof n === 'string') + : (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields)) + ? Object.keys(obj.fields as AnyRec) + : []; objectFields.set(name, new Set(fields)); - } + }); // [#13855] object name → its declared field-group keys, for `section.group`. const objectFieldGroups = indexObjectFieldGroups(stack); diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index c6dcf04111f..0a55ddc867a 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -966,7 +966,17 @@ export function validateListViewFieldRefs( // column, filter key or `groupByField` naming a field a sibling package adds // resolves as it does at runtime. const servedStack = objectsAsServed(stack); - const graph: ObjectGraph = indexObjectGraph({ objects: servedStack.objects }); + // [#30608] Skip 2 is asked of the DECLARED object: one that declares no + // readable field map (an introspected schema) stays `null`, unjudgeable, + // when an extension adds to it, as the write, readonly and sort rules keep + // it. The served graph alone reads such an object as having only the + // extension's fields, and so refused every column naming one of its own. + const declaredGraph = indexObjectGraph(stack); + const graph: ObjectGraph = new Map( + [...indexObjectGraph({ objects: servedStack.objects })].map( + ([name, served]) => [name, declaredGraph.get(name) === null ? null : served] as const, + ), + ); const scope = options?.packageScope; // [#24668] The whole collection, stated — and only without a closure, which // is the narrower set and wins. diff --git a/packages/lint/src/validate-notify-email-field-recipients.ts b/packages/lint/src/validate-notify-email-field-recipients.ts index 9951fe37409..8c3b78712c3 100644 --- a/packages/lint/src/validate-notify-email-field-recipients.ts +++ b/packages/lint/src/validate-notify-email-field-recipients.ts @@ -42,6 +42,11 @@ // map that cannot be seen cannot be judged. The runtime half resolves the field // against the live object and fails the node when it holds no address. // +// [#30608] The field map is the object as SERVED, every `objectExtensions` +// field of the same artifact folded in (`indexFieldTypes`): the live object the +// runtime half reads is the merged one. A field two packages tie on is skipped +// too: its type is registration order, which the stack does not state. +// // Wired via REFERENCE_INTEGRITY_RULES (it resolves a field NAME written in // metadata against what the stack declares), so `os validate`, `os lint`, // `os compile` and the runtime publish door for a flow all refuse it. @@ -50,7 +55,7 @@ import { resolveFlowTriggerKind } from '@objectstack/spec/automation'; import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared'; import { walkFlowNodes, flowNodeLabel } from './flow-walk.js'; -import { recordsOf } from './object-graph.js'; +import { objectsAsServed, recordsOf } from './object-graph.js'; export interface NotifyEmailFieldRecipientFinding { /** Every verdict here gates — see the module note. */ @@ -79,17 +84,37 @@ const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Arra /** The field type the form requires. */ const EMAIL_FIELD_TYPE = 'email'; -/** `fieldName -> type` for every object the stack declares (declared fields only). */ -function indexFieldTypes(stack: AnyRec): Map> { - const out = new Map>(); - for (const obj of recordsOf(stack.objects)) { - if (typeof obj.name !== 'string' || !obj.name) continue; - const types = new Map(); - for (const f of recordsOf(obj.fields)) { - if (typeof f.name === 'string' && f.name) types.set(f.name, typeof f.type === 'string' ? f.type : ''); +/** + * `fieldName -> type` for every object the stack declares, over the objects as + * SERVED ([#30608], `objectsAsServed`, the package's one fold): the runtime + * resolves the field against the live, merged object, so a field an + * `objectExtensions` entry of the same artifact adds is a field of the trigger + * record, and a field an extension re-declares is judged on the extension's + * definition, which replaces the owner's whole (the registry's + * `mergeObjectDefinitions`). `null` marks a field two packages tie on: its name + * is served, and which definition (so which type) wins is registration order, + * which a stack does not state, so it is not judged. An object that declares no + * field keeps its empty map, extended or not: its fields are resolved at + * runtime (module note), and an extension adding to it does not make the rest + * of them visible. + */ +function indexFieldTypes(stack: AnyRec): Map> { + const out = new Map>(); + const declared = recordsOf(stack.objects); + const { objects: served, tied } = objectsAsServed(stack); + declared.forEach((obj, i) => { + if (typeof obj.name !== 'string' || !obj.name) return; + const types = new Map(); + const declaresAny = recordsOf(obj.fields).some((f) => typeof f.name === 'string' && f.name); + const tiedKeys = tied.get(obj.name); + if (declaresAny) { + for (const f of recordsOf(served[i].fields)) { + if (typeof f.name !== 'string' || !f.name) continue; + types.set(f.name, tiedKeys?.has(f.name) ? null : typeof f.type === 'string' ? f.type : ''); + } } out.set(obj.name, types); - } + }); return out; } @@ -118,7 +143,7 @@ function triggerRecordObjectOf(flow: AnyRec): { object: string } | { reason: str /** * Validate every `notify` node's email-field recipient against the trigger - * object's declared fields. Pure `(stack) => Finding[]` (ADR-0019); safe on + * object's served fields (`indexFieldTypes`). Pure `(stack) => Finding[]` (ADR-0019); safe on * pre- or post-parse stacks. */ export function validateNotifyEmailFieldRecipients(stack: AnyRec): NotifyEmailFieldRecipientFinding[] { @@ -128,7 +153,7 @@ export function validateNotifyEmailFieldRecipients(stack: AnyRec): NotifyEmailFi if (flows.length === 0) return findings; // Built lazily: a stack with no email-field recipient never pays it. - let fieldTypes: Map> | null = null; + let fieldTypes: Map> | null = null; flows.forEach((flow, flowIndex) => { const flowName = typeof flow.name === 'string' && flow.name ? flow.name : `#${flowIndex}`; @@ -167,6 +192,8 @@ export function validateNotifyEmailFieldRecipients(stack: AnyRec): NotifyEmailFi if (!declared || declared.size === 0) return; const type = declared.get(field); + // [#30608] A field two packages tie on: served, of no stated type. + if (type === null) return; if (type === undefined) { const emailFields = [...declared].filter(([, t]) => t === EMAIL_FIELD_TYPE).map(([name]) => name); const suggestion = formatSuggestion(findClosestMatches(field, emailFields)); diff --git a/packages/lint/src/validate-page-visualization-bindings.ts b/packages/lint/src/validate-page-visualization-bindings.ts index c01e41579b8..d5b05ee16f4 100644 --- a/packages/lint/src/validate-page-visualization-bindings.ts +++ b/packages/lint/src/validate-page-visualization-bindings.ts @@ -101,7 +101,7 @@ */ import { SYSTEM_FIELDS } from './system-fields.js'; -import { recordsOf } from './object-graph.js'; +import { objectsAsServed, objectsWithoutTiedFields, recordsOf } from './object-graph.js'; export const PAGE_VISUALIZATION_WITHOUT_BINDING = 'page/visualization-without-binding'; @@ -406,11 +406,27 @@ export function validatePageVisualizationBindings(stack: AnyRec): PageVisualizat const findings: PageVisualizationFinding[] = []; if (!isRec(stack)) return findings; + // [#30608] Each object twice: as declared (skip 2's question, the object's + // own list views) and as SERVED (`objectsAsServed`, the package's one fold; + // `served[i]` is the served form of `declared[i]`), the merged object the + // renderer derives from. So a field an `objectExtensions` entry of the same + // artifact adds can supply the binding, and an owner's field an extension + // re-declares is derived on the extension's type, which replaces the owner's + // definition whole (the registry's `mergeObjectDefinitions`). The fold keeps + // the owner's field order, an extension's fields after it, as the merged + // map does. A type is the field's definition, so a field two packages tie on + // is left out of the derivation, and an object carrying one is not reported + // when the derivation misses: the tied field may be the one that binds. const objects = new Map(); - for (const obj of recordsOf(stack.objects)) { + const servedObjects = new Map(); + const servedFold = objectsAsServed(stack); + const withoutTied = objectsWithoutTiedFields(servedFold); + recordsOf(stack.objects).forEach((obj, i) => { const name = strName(obj.name); - if (name && !objects.has(name)) objects.set(name, obj); - } + if (!name || objects.has(name)) return; + objects.set(name, obj); + servedObjects.set(name, withoutTied[i]); + }); const pages = recordsOf(stack.pages); for (let pi = 0; pi < pages.length; pi++) { @@ -427,11 +443,15 @@ export function validatePageVisualizationBindings(stack: AnyRec): PageVisualizat const objectName = strName(cfg.source) ?? strName(page.object); if (!objectName) continue; // Skip 2 — an object this stack does not define, or one with no readable - // field map. Both are unknowable here, never a miss. + // field map. Both are unknowable here, never a miss. [#30608] Asked of the + // DECLARED object: one that declares no field (an introspected schema) + // stays unjudged when an extension adds to it, as the write and readonly + // rules keep such an object; the derivation itself reads the served one. const obj = objects.get(objectName); if (!obj) continue; - const fields = derivableFields(obj); - if (!fields) continue; + if (!derivableFields(obj)) continue; + const fields = derivableFields(servedObjects.get(objectName) ?? obj) ?? []; + const tiedOnObject = (servedFold.tied.get(objectName)?.size ?? 0) > 0; // Skip 4 — a `sourceView` naming a view this stack does not declare. The // runtime hydrates a stored body over the network, so the block may exist @@ -456,6 +476,9 @@ export function validatePageVisualizationBindings(stack: AnyRec): PageVisualizat if (referencedView && viewSuppliesBinding(referencedView, predicate)) continue; if (deriveBinding(fields, predicate)) continue; + // [#30608] A tied field is served with a definition the stack does not + // state; it may supply this binding, so a miss is not a verdict. + if (tiedOnObject) continue; const leading = ai === 0; findings.push({ diff --git a/packages/lint/src/validate-public-form-file-fields.ts b/packages/lint/src/validate-public-form-file-fields.ts index 8daecc03723..e770bfb1ce0 100644 --- a/packages/lint/src/validate-public-form-file-fields.ts +++ b/packages/lint/src/validate-public-form-file-fields.ts @@ -39,6 +39,8 @@ * (folded onto `sections` at parse), because `os lint` hands this rule the * unparsed stack. A form whose bound object, or whose field, does not resolve * in the stack is not judged here: the field-reference rules own that answer. + * A field resolves on the object as served, every `objectExtensions` field of + * the same artifact folded in ({@link indexFieldTypes}). */ import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data'; @@ -49,7 +51,7 @@ import { } from '@objectstack/spec/ui'; import { collectionEntries } from './collection-entries.js'; -import { recordsOf } from './object-graph.js'; +import { objectsAsServed, objectsWithoutTiedFields, recordsOf } from './object-graph.js'; export const PUBLIC_FORM_FILE_FIELD = 'public-form-file-field'; @@ -75,20 +77,37 @@ function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } -/** object name → field name → declared type. Reads the map and the list shapes of `fields`. */ +/** + * object name → field name → type. Reads the map and the list shapes of + * `fields`. + * + * [#30608] Over the objects as SERVED (`objectsAsServed`, the package's one + * fold): the anonymous doors serve the merged object, so a file field an + * `objectExtensions` entry of the same artifact adds is a field the public form + * collects, and an owner's field an extension re-declares is judged on the + * extension's type, which replaces the owner's definition whole (the + * registry's `mergeObjectDefinitions`). The type is the field's definition, so + * a field two packages tie on is left out (`objectsWithoutTiedFields`) and goes + * unjudged: which definition wins is registration order. An object that + * declares no field keeps its empty map, extended or not, as the write and + * readonly rules keep such an object (an introspected schema) unjudged. + */ function indexFieldTypes(stack: AnyRec): Map> { const idx = new Map>(); - for (const obj of recordsOf(stack.objects)) { + const declared = recordsOf(stack.objects); + const served = objectsWithoutTiedFields(objectsAsServed(stack)); + declared.forEach((obj, i) => { const name = strName(obj.name); - if (!name) continue; + if (!name) return; const types = new Map(); - for (const field of recordsOf(obj.fields)) { + const declaresAny = recordsOf(obj.fields).some((field) => strName(field.name)); + for (const field of declaresAny ? recordsOf(served[i].fields) : []) { const fieldName = strName(field.name); const type = strName(field.type); if (fieldName && type) types.set(fieldName, type); } idx.set(name, types); - } + }); return idx; } diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 3b1e462c2aa..731d46c4c82 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -54,7 +54,7 @@ import { import { retiredKeyRemedy } from './validate-retired-permission-residue.js'; import { checkSearchableFieldList, - indexObjectSearchTargets, + indexServedSearchTargets, } from './validate-searchable-fields.js'; import { COMPONENT_FIELD_SPECS, @@ -1154,7 +1154,12 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { // rule's own indexer: it keeps `null` for an object with no authored field // map (external / datasource-introspected), a distinction `indexObjectFields` // flattens — and one this check must honor so both surfaces skip alike. - const searchTargets = indexObjectSearchTargets(stack); + // [#30608] The served one (`indexServedSearchTargets`), the index the + // metadata rule reads: the `$searchFields` override this prop sends reaches + // the ingress gate, which resolves it against the registry's merged object, + // so a field an `objectExtensions` entry of the same artifact adds is + // searchable here too, and the two surfaces still agree. + const searchTargets = indexServedSearchTargets(stack); for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { if (!page || page.kind !== 'react') continue; const source = page.source; diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts index 26c7ca3c145..e126e3803ff 100644 --- a/packages/lint/src/validate-searchable-fields.ts +++ b/packages/lint/src/validate-searchable-fields.ts @@ -158,7 +158,7 @@ import { SEARCH_FLAG_EXCLUDED_TYPES, type SearchFieldMeta, } from '@objectstack/spec/data'; -import { recordsOf, suggestName } from './object-graph.js'; +import { objectsAsServed, recordsOf, suggestName } from './object-graph.js'; import { SYSTEM_FIELDS, indexUnprovisionedAnchors, @@ -324,6 +324,18 @@ function resolveAllowedSet(target: ObjectSearchTarget): { * readable field map, so "declared nothing" stays distinguishable from "not in * stack". Exported alongside `checkSearchableFieldList` so every surface that * authors a searchable set resolves against the identical index (#4329). + * + * [#30608] This is the DECLARED reading, and stays one: it reads the objects + * it is handed, folds no `objectExtensions` entry, and is the primitive the + * served readings compose ({@link indexServedSearchTargets}, and the sort + * rule's `indexServedSortFields`, which hands it pre-served objects and reads + * its `null` skip off the declared ones). Changing it would move every + * out-of-repo caller of this exported core at once. Both in-repo search + * surfaces read the served index: `validateSearchableFields` and a react + * page's `` (`validate-react-page-props`), whose + * `$searchFields` override reaches the same ingress gate, which resolves the + * set against `registry.getObject` (`resolveQueryFields`, metadata-protocol), + * the folded object. */ export function indexObjectSearchTargets( stack: Record, @@ -337,6 +349,48 @@ export function indexObjectSearchTargets( return fieldsByObject; } +/** + * [#30608] {@link indexObjectSearchTargets} over the objects as SERVED + * (`objectsAsServed`, the package's one fold): the runtime resolves a + * searchable set against the registry's merged object (`resolveQueryFields` + * reads `registry.getObject`, metadata-protocol; the engine's search + * expansion reads the same schema), so a field an `objectExtensions` entry of + * the same artifact adds is a field of the object, and an owner's field an + * extension re-declares is judged on the extension's definition, which + * replaces the owner's whole (the registry's `mergeObjectDefinitions`). + * + * - The type, `hidden` and `searchable` meta are the field's definition, so a + * field two packages tie on keeps its name (read off the served objects, + * ties kept) and gets a stub meta (`{}`), + * the way `resolveAllowedSet` stubs a system column: existence holds, the + * virtual and credential checks have no type to read, and the auto-default + * admissibility pass reads an unreadable type as unresolvable, not wrong. + * - An object that declares no field map keeps its declared `null` (skip ②), + * extended or not: an extension adding to an introspected schema does not + * make the rest of it knowable. + * - The object's own `searchableFields`, `nameField` and `displayNameField` + * are the owner's: only the fields move in the fold. + */ +export function indexServedSearchTargets( + stack: Record, +): Map { + if (!isRec(stack)) return new Map(); + const served = objectsAsServed(stack); + // Names from the served objects, ties kept, THEN the tied metas blanked: a + // target built from the tie-dropped objects is `null` when every served + // field is tied, which would turn the existence check off. + const out = indexObjectSearchTargets({ objects: served.objects }); + for (const [name, tiedKeys] of served.tied) { + const target = out.get(name); + if (!target) continue; + for (const key of tiedKeys) target.fields[key] = {}; + } + for (const [name, target] of indexObjectSearchTargets(stack)) { + if (target === null) out.set(name, null); + } + return out; +} + /** * Check one `searchableFields` array against the target `fieldsByObject` * holds for `objectName` — the shared core behind every surface that authors a @@ -582,8 +636,12 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ const findings: SearchableFieldFinding[] = []; if (!isRec(stack)) return findings; + // [#30608] The walk below stays on the declared objects, so every finding + // keeps its declared path; the index it resolves against is the served one + // ({@link indexServedSearchTargets}). The provenance index is object-level + // (`external`, the injected anchors) and stays on the declared objects. const objects = recordsOf(stack.objects); - const fieldsByObject = indexObjectSearchTargets(stack); + const fieldsByObject = indexServedSearchTargets(stack); const unprovisionedAnchors = indexUnprovisionedAnchors(stack); const check = (