diff --git a/.changeset/30383-field-path-rules-extension-fields.md b/.changeset/30383-field-path-rules-extension-fields.md new file mode 100644 index 00000000000..0a2e6e5ed76 --- /dev/null +++ b/.changeset/30383-field-path-rules-extension-fields.md @@ -0,0 +1,11 @@ +--- +'@objectstack/lint': patch +--- + +fix(lint): the field-path rules judge an object as the registry serves it, so a view, report, dataset, page, widget, mapping, related list or row policy naming a field that an `objectExtensions` entry adds is no longer refused (#30383) + +Clause-②: yes (widening: field-path rules accept fields a sibling package adds through `objectExtensions`, which the runtime already serves; any rule that deliberately stays on declared fields says why in its header) +Metadata-protocol: none (lint judgements, no authorable key) + +- **Before.** Twelve rules resolved a field path against the objects' declared `fields` only: dataset fields and filters, report runtime filters, dashboard widget filters, list view columns and keys, an object's own field lists, page bindings, filter preset comparands, RLS predicates, sharing-rule criteria, thank-you panels, measure aggregates and the field-consumer credit for dataset paths. On a multi-package artifact, where a module package adds fields to an app package's object through `objectExtensions`, a path naming one of those fields read as unknown (for example `dataset-field-unknown`, `list-view-field-unknown`, `rls-predicate-unknown-field`) although the runtime serves the field. Two rules folded extensions through readers of their own: `related-list-ref-unknown` read only the stack's top-level `objectExtensions`, so an inbound lookup a sibling package adds was not seen, and `mapping-target-field-unknown` kept every entry, where the registry keeps one per package per object. +- **Now.** Every one of them reads the one shared fold in `object-graph.ts`: the object's declared fields plus every field an `objectExtensions` entry of any body of the artifact adds, one entry per package per object, a later entry replacing an earlier one. A field no body declares or adds keeps the same refusal, with the same rule id, severity and message. The rules that judge a field's type (measure aggregates, preset comparands, sharing-rule criteria, thank-you panels) judge an extension-added field on its served definition, as they judge a declared one. A field two packages write at the same deciding priority is still a served name, but which definition wins is registration order, so the rules that judge its type leave it unjudged, and a thank-you panel admits it only when every such definition is engine-generated. diff --git a/packages/lint/src/object-graph.test.ts b/packages/lint/src/object-graph.test.ts index 99fd9f185a2..aaafbf175cb 100644 --- a/packages/lint/src/object-graph.test.ts +++ b/packages/lint/src/object-graph.test.ts @@ -20,6 +20,20 @@ import { RELATIONSHIP_FIELD_TYPES, } from './object-graph.js'; import { walkFilterFieldKeys, type FilterFieldKey } from './filter-walk.js'; +import { DATASET_FIELD_UNKNOWN, validateDatasetReferences } from './validate-dataset-references.js'; +import { MEASURE_AGGREGATE_FIELD_TYPE_REFUSED, validateDatasetMeasureAggregates } from './validate-dataset-measure-aggregates.js'; +import { FIELD_NO_CONSUMERS, validateFieldConsumers } from './validate-field-consumers.js'; +import { LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_KANBAN_LANE_UNKNOWN, validateListViewFieldRefs } from './validate-list-view-field-refs.js'; +import { MAPPING_TARGET_FIELD_UNKNOWN, validateMappingTargetFields } from './validate-mapping-target-fields.js'; +import { OBJECT_FIELD_REF_UNKNOWN, validateObjectFieldRefs } from './validate-object-field-refs.js'; +import { PAGE_FIELD_UNKNOWN, validatePageFieldBindings } from './validate-page-field-bindings.js'; +import { FILTER_PRESET_COMPARAND, validatePresetComparands } from './validate-preset-comparands.js'; +import { RELATED_LIST_REF_UNKNOWN, validateRelatedListRefs } from './validate-related-list-refs.js'; +import { REPORT_FILTER_FIELD_UNKNOWN, validateReportRuntimeFilters } from './validate-report-runtime-filter.js'; +import { RLS_PREDICATE_UNKNOWN_FIELD, validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js'; +import { SHARING_RULE_UNLOWERABLE_CONDITION, validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js'; +import { THANK_YOU_RECORD_FIELD, validateThankYouRecordFields } from './validate-thank-you-record-fields.js'; +import { MEASURE_AGGREGATE_INCOHERENT, WIDGET_FILTER_FIELD_UNKNOWN, validateWidgetBindings } from './validate-widget-bindings.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'; @@ -532,3 +546,262 @@ describe('[#19289] object-graph — a `user` field takes its target from the TYP expect(() => indexObjectGraph(broken)).toThrow(/`reference` is an object/); }); }); + +// [#30383] The field-path rules resolve through the fold above. One artifact +// for all of them: a module package (`mod`) adds fields to the app package's +// `xs_deal` through `objectExtensions`, the HotCRM shape. Per rule: a path +// naming an extension-added field passes, the same stack without the +// extension still refuses it (the pin is not vacuous), and a field no body +// declares or extends is refused under the same rule id as before. +describe('[#30383] the field-path rules read the objects as served', () => { + type Rec = Record; + type Run = (stack: Rec) => ReadonlyArray<{ rule: string }>; + const deal = { name: 'xs_deal', fields: { name: { type: 'text' }, account: { type: 'lookup', reference: 'xs_account' } } }; + const account = { name: 'xs_account', fields: { name: { type: 'text' }, tier: { type: 'text' } } }; + const note = { name: 'xs_note', fields: { body: { type: 'text' } } }; + const added = { + region: { type: 'text' }, + score: { type: 'number' }, + closed_on: { type: 'date' }, + ref_no: { type: 'autonumber' }, + partner: { type: 'lookup', reference: 'xs_account' }, + }; + const extensions = [ + { extend: 'xs_deal', fields: added }, + { extend: 'xs_note', fields: { deal_id: { type: 'lookup', reference: 'xs_deal' } } }, + ]; + const artifact = (extra: Rec, objectExtensions: unknown[] = extensions): Rec => ({ + objects: [deal, account, note], + packages: [ + { manifest: { id: 'app', objects: [deal, account, note] } }, + { manifest: { id: 'mod', objectExtensions } }, + ], + ...extra, + }); + const ids = (run: Run, stack: Rec) => run(stack).map((f) => f.rule); + const dataset = (field: string) => ({ + name: 'deals', object: 'xs_deal', include: ['partner'], + dimensions: [{ name: 'd', field }], measures: [{ name: 'n', aggregate: 'count' }], + }); + const widget = (filter: Rec) => ({ + datasets: [dataset('name')], + dashboards: [{ name: 'b', widgets: [{ id: 'w', type: 'metric', dataset: 'deals', values: ['n'], filter }] }], + }); + + // rule · its unknown-field id · the stack naming FIELD + const existence: Array<[string, string, Run, (field: string) => Rec]> = [ + ['validate-dataset-references', DATASET_FIELD_UNKNOWN, validateDatasetReferences, (f) => ({ datasets: [dataset(f)] })], + ['validate-report-runtime-filter', REPORT_FILTER_FIELD_UNKNOWN, validateReportRuntimeFilters, (f) => ({ + datasets: [dataset('name')], + reports: [{ name: 'r', label: 'R', type: 'summary', dataset: 'deals', values: ['n'], rows: ['d'], runtimeFilter: { [f]: 'x' } }], + })], + ['validate-widget-bindings', WIDGET_FILTER_FIELD_UNKNOWN, validateWidgetBindings, (f) => widget({ [f]: 'x' })], + ['validate-list-view-field-refs', LIST_VIEW_FIELD_UNKNOWN, validateListViewFieldRefs, (f) => ({ + views: [{ name: 'v', object: 'xs_deal', list: { type: 'grid', columns: ['name', f] } }], + })], + ['validate-object-field-refs', OBJECT_FIELD_REF_UNKNOWN, validateObjectFieldRefs, (f) => ({ + objects: [{ ...deal, highlightFields: ['name', f] }, account, note], + })], + ['validate-page-field-bindings', PAGE_FIELD_UNKNOWN, validatePageFieldBindings, (f) => ({ + pages: [{ + name: 'p', object: 'xs_deal', + regions: [{ name: 'main', components: [{ type: 'record:highlights', properties: { fields: ['name', f] } }] }], + }], + })], + ['validate-rls-predicate-enforceability', RLS_PREDICATE_UNKNOWN_FIELD, validateRlsPredicateEnforceability, (f) => ({ + permissions: [{ name: 's', label: 'S', rowLevelSecurity: [{ name: 'p', object: 'xs_deal', operation: 'select', using: `record.${f} == 'x'` }] }], + })], + ['validate-thank-you-record-fields', THANK_YOU_RECORD_FIELD, validateThankYouRecordFields, (f) => ({ + views: [{ + name: 'vv', + form: { + type: 'simple', data: { provider: 'object', object: 'xs_deal' }, sections: [{ name: 'a', fields: ['name'] }], + submitBehavior: { kind: 'thank-you', title: 'T', message: `Ref {{record.${f}}}` }, + }, + }], + })], + ['validate-mapping-target-fields', MAPPING_TARGET_FIELD_UNKNOWN, validateMappingTargetFields, (f) => ({ + mappings: [{ name: 'm', targetObject: 'xs_deal', fieldMapping: [{ source: 'R', target: f }] }], + })], + ]; + // The per-package leg of `os build` (`packageBodyAsStack` in the cli): the + // app body is the stack, its own collections at the top level and under + // `manifest`, and the artifact's `packages[]` beside them. + const perPackage = (extra: Rec, objectExtensions: unknown[] = extensions): Rec => { + const body = { id: 'app', objects: [deal, account, note], ...extra }; + return { ...body, manifest: body, packages: [{ manifest: body }, { manifest: { id: 'mod', objectExtensions } }] }; + }; + it.each(existence)('%s: an extension-added field passes; a field nothing adds is still %s', (_rule, id, run, naming) => { + // `ref_no` (an autonumber) is the one name every member admits, the + // thank-you panel included. + expect(ids(run, artifact(naming('ref_no')))).not.toContain(id); + expect(ids(run, artifact(naming('ref_no'), []))).toContain(id); + expect(ids(run, artifact(naming('nowhere')))).toContain(id); + }); + + it.each(existence)('%s, on the per-package leg: the same verdicts', (_rule, id, run, naming) => { + expect(ids(run, perPackage(naming('ref_no')))).not.toContain(id); + expect(ids(run, perPackage(naming('ref_no'), []))).toContain(id); + expect(ids(run, perPackage(naming('nowhere')))).toContain(id); + }); + + it.each(existence)('%s: a field nothing adds is refused byte-for-byte as on the declared objects alone', (_rule, id, run, naming) => { + const refusal = (stack: Rec) => run(stack) + .filter((f) => f.rule === id) + .map((f) => { const { severity, where, path, message } = f as Rec; return { severity, where, path, message }; }); + const served = refusal(artifact(naming('zq_absent'))); + expect(served).toHaveLength(1); + expect(served).toEqual(refusal(artifact(naming('zq_absent'), []))); + }); + + it('validate-dataset-references: a path through an extension-added relationship resolves', () => { + expect(ids(validateDatasetReferences, artifact({ datasets: [dataset('partner.tier')] }))).toEqual([]); + expect(ids(validateDatasetReferences, artifact({ datasets: [dataset('partner.nowhere')] }))).toEqual([DATASET_FIELD_UNKNOWN]); + }); + + // The type arms judge an extension-added field on its served definition, + // as they judge a declared one; a name nothing adds stays the existence + // rules' finding, not theirs. + it('validate-dataset-measure-aggregates: SUM over an extension-added field is judged on its served type', () => { + const sum = (field: string) => ({ datasets: [{ name: 'deals', object: 'xs_deal', measures: [{ name: 's', aggregate: 'sum', field }] }] }); + expect(ids(validateDatasetMeasureAggregates, artifact(sum('score')))).toEqual([]); + expect(ids(validateDatasetMeasureAggregates, artifact(sum('region')))).toEqual([MEASURE_AGGREGATE_FIELD_TYPE_REFUSED]); + expect(ids(validateDatasetMeasureAggregates, artifact(sum('nowhere')))).toEqual([]); + }); + + it('validate-preset-comparands: a preset compared with an extension-added date field is judged as on a declared one', () => { + expect(ids(validatePresetComparands, artifact(widget({ closed_on: 'last_30_days' })))).toEqual([FILTER_PRESET_COMPARAND]); + expect(ids(validatePresetComparands, artifact(widget({ region: 'last_30_days' })))).toEqual([]); + }); + + it('validate-sharing-rule-enforceability: a criterion on an extension-added field is judged on its served type', () => { + const rule = (condition: string) => ({ sharingRules: [{ name: 'sr', object: 'xs_deal', condition, sharedWith: { type: 'role', value: 'r' }, accessLevel: 'read' }] }); + expect(ids(validateSharingRuleEnforceability, artifact(rule('record.region == record.name')))).toEqual([]); + expect(ids(validateSharingRuleEnforceability, artifact(rule('record.closed_on == record.name')))).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]); + }); + + it('validate-field-consumers: a dataset path through an extension-added lookup credits the field it reaches', () => { + const tierFindings = (stack: Rec) => + validateFieldConsumers(stack).filter((f) => f.object === 'xs_account' && f.field === 'tier').map((f) => f.rule); + expect(tierFindings(artifact({ datasets: [dataset('partner.tier')] }))).toEqual([]); + expect(tierFindings(artifact({ datasets: [dataset('name')] }))).toEqual([FIELD_NO_CONSUMERS]); + // Extension-added fields are still not judged themselves (the header). + expect(validateFieldConsumers(artifact({ datasets: [dataset('name')] })).filter((f) => f.field in added)).toEqual([]); + }); + + it('validate-related-list-refs, on the per-package leg: a child another body owns stays unjudged', () => { + // `lib` owns `xs_note` and its FK; `mod` extends `xs_note` with something + // else. The app body's leg holds neither declaration, so it must not read + // `mod`'s extension as `xs_note`'s whole field set. + const body = { id: 'app', objects: [{ ...deal, relatedLists: ['xs_note', 'xs_note.deal_id'] }, account] }; + const lib = { id: 'lib', objects: [{ name: 'xs_note', fields: { deal_id: { type: 'lookup', reference: 'xs_deal' } } }] }; + const mod = { id: 'mod', objectExtensions: [{ extend: 'xs_note', fields: { mood: { type: 'text' } } }] }; + const stack = { ...body, manifest: body, packages: [{ manifest: body }, { manifest: lib }, { manifest: mod }] }; + expect(ids(validateRelatedListRefs, stack)).toEqual([]); + }); + + it('validate-related-list-refs: an FK a sibling package adds is an inbound relationship', () => { + const lists = (relatedLists: string[]) => ({ objects: [{ ...deal, relatedLists }, account, note] }); + expect(ids(validateRelatedListRefs, artifact(lists(['xs_note', 'xs_note.deal_id'])))).toEqual([]); + expect(ids(validateRelatedListRefs, artifact(lists(['xs_note']), []))).toEqual([RELATED_LIST_REF_UNKNOWN]); + expect(ids(validateRelatedListRefs, artifact(lists(['xs_note.nowhere'])))).toEqual([RELATED_LIST_REF_UNKNOWN]); + }); + + // The two retired private readers folded every entry additively; the shared + // fold keeps the registry's one entry per package per target, so a later + // entry of the same package replaces an earlier one whole. + it('both retired readers now keep one entry per package per target, as the registry does', () => { + const replaced = [ + { extend: 'xs_note', fields: { deal_id: { type: 'lookup', reference: 'xs_deal' } } }, + { extend: 'xs_note', fields: { body_html: { type: 'html' } } }, + { extend: 'xs_deal', fields: { region: { type: 'text' } } }, + { extend: 'xs_deal', fields: { score: { type: 'number' } } }, + ]; + const lists = { objects: [{ ...deal, relatedLists: ['xs_note'] }, account, note] }; + expect(ids(validateRelatedListRefs, artifact(lists, replaced))).toEqual([RELATED_LIST_REF_UNKNOWN]); + const mapping = (target: string) => ({ mappings: [{ name: 'm', targetObject: 'xs_deal', fieldMapping: [{ source: 'R', target }] }] }); + expect(ids(validateMappingTargetFields, artifact(mapping('score'), replaced))).toEqual([]); + expect(ids(validateMappingTargetFields, artifact(mapping('region'), replaced))).toEqual([MAPPING_TARGET_FIELD_UNKNOWN]); + }); + + // Two packages writing one field at the deciding priority: the name is + // served, but which definition wins is registration order, which the stack + // does not state. The type arms leave it unjudged; the thank-you panel, + // shown to anonymous visitors, fails closed. + describe('a field two packages tie on', () => { + const tie = (a: Rec, b: Rec): Rec => ({ + objects: [deal, account, note], + packages: [ + { manifest: { id: 'app', objects: [deal, account, note] } }, + { manifest: { id: 'mod', objectExtensions: [{ extend: 'xs_deal', fields: a }] } }, + { manifest: { id: 'mod2', objectExtensions: [{ extend: 'xs_deal', fields: b }] } }, + ], + }); + // The fold keeps the LAST writer at a tied priority, so each pair below + // lists the definition a type arm would refuse last: an arm that read the + // kept definition instead of dropping the tie goes red. + const numberOrText = (extra: Rec) => ({ ...tie({ score: { type: 'number' } }, { score: { type: 'text' } }), ...extra }); + const textOrDate = (extra: Rec) => ({ ...tie({ closed_on: { type: 'text' } }, { closed_on: { type: 'date' } }), ...extra }); + const numberOrPercent = (extra: Rec) => ({ ...tie({ score: { type: 'number' } }, { score: { type: 'percent' } }), ...extra }); + const stage = (values: string[]) => ({ stage: { type: 'select', options: values.map((value) => ({ value, label: value })) } }); + const abOrAc = (extra: Rec) => ({ ...tie(stage(['a', 'b']), stage(['a', 'c'])), ...extra }); + + it('is still a served name for the existence rules', () => { + expect(ids(validateDatasetReferences, numberOrText({ datasets: [{ ...dataset('score'), include: [] }] }))).toEqual([]); + expect(ids(validateDatasetReferences, numberOrText({ datasets: [{ ...dataset('zq_absent'), include: [] }] }))).toEqual([DATASET_FIELD_UNKNOWN]); + }); + + it('is not judged on its type by measure aggregates', () => { + const sum = { datasets: [{ name: 'deals', object: 'xs_deal', measures: [{ name: 's', aggregate: 'sum', field: 'score' }] }] }; + expect(ids(validateDatasetMeasureAggregates, numberOrText(sum))).toEqual([]); + expect(ids(validateDatasetMeasureAggregates, { ...tie({ score: { type: 'text' } }, {}), ...sum })) + .toEqual([MEASURE_AGGREGATE_FIELD_TYPE_REFUSED]); + }); + + it('is not judged on its type by preset comparands', () => { + const preset = widget({ closed_on: 'last_30_days' }); + expect(ids(validatePresetComparands, textOrDate(preset))).toEqual([]); + expect(ids(validatePresetComparands, { ...tie({ closed_on: { type: 'date' } }, {}), ...preset })).toEqual([FILTER_PRESET_COMPARAND]); + }); + + it('is not judged on its type by sharing-rule criteria', () => { + const rule = { sharingRules: [{ name: 'sr', object: 'xs_deal', condition: 'record.closed_on == record.name', sharedWith: { type: 'role', value: 'r' }, accessLevel: 'read' }] }; + expect(ids(validateSharingRuleEnforceability, textOrDate(rule))).toEqual([]); + expect(ids(validateSharingRuleEnforceability, { ...tie({ closed_on: { type: 'date' } }, {}), ...rule })) + .toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]); + }); + + it('is not judged on its type by the widget field-type map', () => { + const sum = { datasets: [{ name: 'deals', object: 'xs_deal', measures: [{ name: 's', aggregate: 'sum', field: 'score' }] }] }; + expect(ids(validateWidgetBindings, numberOrPercent(sum))).toEqual([]); + expect(ids(validateWidgetBindings, { ...tie({ score: { type: 'percent' } }, {}), ...sum })).toEqual([MEASURE_AGGREGATE_INCOHERENT]); + }); + + it('lends no options to a kanban lane check', () => { + const lanes = { + views: [{ name: 'v', object: 'xs_deal', list: { type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'], lanes: ['b'] } } }], + }; + expect(ids(validateListViewFieldRefs, abOrAc(lanes))).toEqual([]); + expect(ids(validateListViewFieldRefs, { ...tie(stage(['a', 'c']), {}), ...lanes })).toEqual([LIST_VIEW_KANBAN_LANE_UNKNOWN]); + }); + + it('is admitted on a thank-you panel only when every tied definition is engine-generated', () => { + const panel = (stack: Rec) => ({ + ...stack, + views: [{ + name: 'vv', + form: { + type: 'simple', data: { provider: 'object', object: 'xs_deal' }, sections: [{ name: 'a', fields: ['name'] }], + submitBehavior: { kind: 'thank-you', title: 'T', message: 'Ref {{record.ref_no}}' }, + }, + }], + }); + expect(ids(validateThankYouRecordFields, panel(tie({ ref_no: { type: 'autonumber' } }, { ref_no: { type: 'autonumber' } })))).toEqual([]); + for (const [a, b] of [['autonumber', 'text'], ['text', 'autonumber']]) { + const findings = validateThankYouRecordFields(panel(tie({ ref_no: { type: a } }, { ref_no: { type: b } }))); + expect(findings.map((f) => f.rule)).toEqual([THANK_YOU_RECORD_FIELD]); + expect(findings[0].message).toContain('is a text field'); + } + }); + }); +}); diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index 54c71089aec..a3ff9236a8c 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -792,3 +792,42 @@ export function listNames(names: Iterable): string { const arr = [...names].sort(); return arr.length > 0 ? arr.join(', ') : '(none)'; } + +// [#30383] Kept below the fold's section on purpose: `foldCode()` in +// `validate-security-posture.test.ts` appends this file's text from +// `export interface VisibleObjectExtension` to `/** What a path resolved to` to +// the posture rule's code and holds every receiver read there to its tables; +// these two read only the fold's output. + +/** + * [#30383] The served objects with every `tied` field taken out: what a rule + * may judge a field's DEFINITION on. A field two packages write at the + * priority that decides it is served, but which definition wins is the + * registration order, and the registry registers an artifact's packages in + * dependency-topological order (`resolveArtifactPackageOrder`), not in the + * `packages[]` order the fold breaks ties by. So a type arm leaves a tied field + * unjudged, as the membership-key rule does, and a rule that only asks whether + * the name is served keeps it ({@link indexServedObjectGraph}'s default). + */ +export function objectsWithoutTiedFields(served: { objects: AnyRec[]; tied: Map> }): AnyRec[] { + if (served.tied.size === 0) return served.objects; + return served.objects.map((obj) => { + const name = strName(obj.name); + const tiedKeys = name ? served.tied.get(name) : undefined; + if (!tiedKeys) return obj; + return { ...obj, fields: recordsOf(obj.fields).filter((f) => !tiedKeys.has(strName(f.name) ?? '')) }; + }); +} + +/** + * [#30383] {@link indexObjectGraph} over {@link objectsAsServed}: the graph of + * the objects the registry serves, every `objectExtensions` field folded in. + * The field-path rules of this package resolve through it, so a path naming a + * field a sibling package adds resolves as it does at runtime, and a field no + * body declares or extends is still a miss. `{ tied: 'drop' }` is the graph a + * type arm judges on ({@link objectsWithoutTiedFields}). + */ +export function indexServedObjectGraph(stack: unknown, options: { tied?: 'keep' | 'drop' } = {}): ObjectGraph { + const served = objectsAsServed(stack); + return indexObjectGraph({ objects: options.tied === 'drop' ? objectsWithoutTiedFields(served) : served.objects }); +} diff --git a/packages/lint/src/validate-dataset-measure-aggregates.ts b/packages/lint/src/validate-dataset-measure-aggregates.ts index 62e68da86a5..753ac03e22f 100644 --- a/packages/lint/src/validate-dataset-measure-aggregates.ts +++ b/packages/lint/src/validate-dataset-measure-aggregates.ts @@ -222,7 +222,7 @@ import { DATASET_DURATION_FIELD_TYPE } from '@objectstack/spec/ui'; import { collectionEntries } from './collection-entries.js'; import { - indexObjectGraph, + indexServedObjectGraph, isUnjudgeable, recordsOf, resolveFieldPath, @@ -651,7 +651,9 @@ export function validateDatasetMeasureAggregates(stack: unknown): DatasetMeasure const cubes = collectionEntries(stack.analyticsCubes, 'analyticsCubes'); if (datasets.length === 0 && cubes.length === 0) return findings; - const graph: ObjectGraph = indexObjectGraph(stack); + // [#30383] Served, a field two packages tie on left out: which definition + // wins is registration order, so its type is not judged here. + const graph: ObjectGraph = indexServedObjectGraph(stack, { tied: 'drop' }); datasets.forEach((ds, di) => { // ── Skip 1: no base object, or one the graph cannot answer for ── diff --git a/packages/lint/src/validate-dataset-references.ts b/packages/lint/src/validate-dataset-references.ts index de831ff2c0b..846454bb582 100644 --- a/packages/lint/src/validate-dataset-references.ts +++ b/packages/lint/src/validate-dataset-references.ts @@ -119,7 +119,7 @@ import { walkFilterFieldKeys } from './filter-walk.js'; import { describeFieldPathVerdict, - indexObjectGraph, + indexServedObjectGraph, isUnjudgeable, joinablePrefixes, recordsOf, @@ -182,7 +182,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { const datasets = recordsOf(stack.datasets); if (datasets.length === 0) return findings; - const graph: ObjectGraph = indexObjectGraph(stack); + const graph: ObjectGraph = indexServedObjectGraph(stack); datasets.forEach((ds, di) => { const dsName = strName(ds.name) ?? `#${di}`; diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index e9804197f00..f6f9d5441a6 100644 --- a/packages/lint/src/validate-field-consumers.ts +++ b/packages/lint/src/validate-field-consumers.ts @@ -165,7 +165,10 @@ * `controlled_by_parent` sharing, roll-ups and inline grids as its * readers; the relationship is consumed by being declared). * - **Object extensions** (`objectExtensions`) are not judged: the fields - * they add belong to objects this stack does not own. + * they add belong to objects this stack does not own. They are still + * READ (#30383): a dataset or cube path is resolved over the objects as + * served, so a hop through an extension-added relationship credits the + * declared field it reaches. * * Severity is `warning` and stays so: a refusal would narrow the authorable * surface (today-valid metadata would start being refused), which is the @@ -183,7 +186,7 @@ import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; import { referenceTargetOf } from '@objectstack/spec/data'; import { collectionEntries } from './collection-entries.js'; import { - indexObjectGraph, + indexServedObjectGraph, isUnjudgeable, joinablePrefixes, recordsOf, @@ -1194,7 +1197,7 @@ function creditColumnPath( * walk. */ function creditAnalyticsColumns(ledger: ConsumerLedger, stack: AnyRec): void { - const graph = indexObjectGraph(stack); + const graph = indexServedObjectGraph(stack); for (const { rec: ds, path: dsPath } of collectionEntries(stack.datasets, 'datasets')) { const object = strName(ds.object); if (!object || !graph.get(object)) continue; diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index f1d597c3ec0..c6dcf04111f 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -428,6 +428,8 @@ import { describeFieldPathVerdict, indexObjectGraph, isUnjudgeable, + objectsAsServed, + objectsWithoutTiedFields, recordsOf, resolveFieldPath, suggestName, @@ -959,7 +961,12 @@ export function validateListViewFieldRefs( const findings: ListViewFieldRefFinding[] = []; if (!isRec(stack)) return findings; - const graph: ObjectGraph = indexObjectGraph(stack); + // [#30383] The objects as the registry serves them, every `objectExtensions` + // field folded in: the graph and the option map below both read this, so a + // 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 }); const scope = options?.packageScope; // [#24668] The whole collection, stated — and only without a closure, which // is the narrower set and wins. @@ -1058,11 +1065,13 @@ export function validateListViewFieldRefs( /** * [#28018] The inline option values each object's fields declare, read from - * the same `stack.objects` the graph indexes. A field with no non-empty - * inline `options` array is absent: its lanes are not judged. + * the same served objects the graph indexes. A field with no non-empty + * inline `options` array is absent: its lanes are not judged. [#30383] So is + * a field two packages tie on: which definition's options win is + * registration order. */ const optionValues = new Map>>(); - for (const obj of recordsOf(stack.objects)) { + for (const obj of objectsWithoutTiedFields(servedStack)) { const objName = strName(obj.name); if (!objName) continue; const byField = new Map>(); diff --git a/packages/lint/src/validate-mapping-target-fields.test.ts b/packages/lint/src/validate-mapping-target-fields.test.ts index 155e686b8e2..ea8ece2ab8a 100644 --- a/packages/lint/src/validate-mapping-target-fields.test.ts +++ b/packages/lint/src/validate-mapping-target-fields.test.ts @@ -181,22 +181,31 @@ describe('validateMappingTargetFields', () => { }); it('folds fields an objectExtensions entry merges into the object — from the stack and from a sibling package', () => { - const stack = { + const mappings = [mapping([ + { source: 'Tier', target: 'sla_tier' }, + { source: 'Region', target: 'region' }, + ])]; + // [#30383] Read through the shared fold, as the registry registers them: + // the stack's own entries when it carries no `packages[]`, and every + // body's when it does (an artifact's top level repeats its bodies). + expect(validateMappingTargetFields({ objects: [contact], - objectExtensions: [{ extend: 'crm_contact', fields: { sla_tier: { type: 'text' } } }], - packages: [{ manifest: { objectExtensions: [{ extend: 'crm_contact', fields: { region: { type: 'text' } } }] } }], - mappings: [mapping([ - { source: 'Tier', target: 'sla_tier' }, - { source: 'Region', target: 'region' }, - ])], - }; - expect(validateMappingTargetFields(stack)).toEqual([]); + objectExtensions: [{ extend: 'crm_contact', fields: { sla_tier: { type: 'text' }, region: { type: 'text' } } }], + mappings, + })).toEqual([]); + expect(validateMappingTargetFields({ + objects: [contact], + packages: [ + { manifest: { objects: [contact], objectExtensions: [{ extend: 'crm_contact', fields: { sla_tier: { type: 'text' } } }] } }, + { manifest: { objectExtensions: [{ extend: 'crm_contact', fields: { region: { type: 'text' } } }] } }, + ], + mappings, + })).toEqual([]); }); it('CONTROL — `packages: undefined` (absent) stays silent — the only value this reader treats as absent', () => { - // `full_name`, not `sla_tier`: `sla_tier` resolves via the STACK's own - // top-level `objectExtensions` (the test above, :173) — `region` is the - // one that needs a package (:174). Either way, with `packages` genuinely + // `full_name`, not `sla_tier`: `sla_tier` resolves only through an + // `objectExtensions` entry (the test above). With `packages` genuinely // absent this control needs a field `contact` declares on its own, so a // real finding can't masquerade as the reader silently accepting the // shape. diff --git a/packages/lint/src/validate-mapping-target-fields.ts b/packages/lint/src/validate-mapping-target-fields.ts index 6c2682e6a5d..6d921e9f9a7 100644 --- a/packages/lint/src/validate-mapping-target-fields.ts +++ b/packages/lint/src/validate-mapping-target-fields.ts @@ -51,8 +51,10 @@ * * Fields an `objectExtensions[]` entry merges into the object (from this stack * or from a sibling package of the same artifact) ARE addressable at runtime, - * because the registry merges every extension layer onto its target, so they - * are folded into the object's field map before it is judged. Fields injected + * because the registry merges every extension layer onto its target, so the + * object is judged as served: through the package's one fold + * (`objectsAsServed`, #30383), which keeps one entry per package per target + * as the registry does. Fields injected * imperatively by plugin code are not metadata and no static rule can see * them; the import door, which reads the registered object, still judges them. */ @@ -64,7 +66,7 @@ import { type ImportMappingTargetHead, } from '@objectstack/spec/data'; -import { packagesOf, recordsOf, suggestName } from './object-graph.js'; +import { objectsAsServed, packagesOf, recordsOf, suggestName } from './object-graph.js'; /** * A `fieldMapping[].target` the mapping cannot write on its object: it names no @@ -94,30 +96,6 @@ function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } -/** - * Target object name → the field records every `objectExtensions[]` entry - * aimed at it contributes, read from the stack in hand and from each - * `packages[].manifest` body (the per-package leg of `os build` sees a - * sibling's extension only there). - */ -function extensionFieldsByTarget(stack: AnyRec): Map { - const byTarget = new Map(); - const add = (extensions: unknown) => { - for (const extension of recordsOf(extensions)) { - const target = strName(extension.extend); - if (!target) continue; - const fields = recordsOf(extension.fields); - if (fields.length === 0) continue; - byTarget.set(target, [...(byTarget.get(target) ?? []), ...fields]); - } - }; - add(stack.objectExtensions); - for (const entry of packagesOf(stack)) { - if (isRec(entry.manifest)) add(entry.manifest.objectExtensions); - } - return byTarget; -} - /** * Report every import-mapping target its mapping cannot write on its object: * one that names no field and no declared part of a compound field, and a @@ -130,23 +108,21 @@ export function validateMappingTargetFields(stack: AnyRec): MappingTargetFieldFi const mappings = recordsOf(stack.mappings); if (mappings.length === 0) return findings; + // A present non-array `packages` is still refused (ruling A on #15293): the + // shared fold reads such a value as absent, so the refusal is asked here. + packagesOf(stack); const objects = new Map(); - for (const obj of recordsOf(stack.objects)) { + for (const obj of objectsAsServed(stack).objects) { const n = strName(obj.name); if (n) objects.set(n, obj); } - const extensionFields = extensionFieldsByTarget(stack); for (let mi = 0; mi < mappings.length; mi++) { const mapping = mappings[mi]; const objectName = strName(mapping.targetObject); if (!objectName) continue; // a missing targetObject is the schema's subject - const declared = objects.get(objectName); - if (!declared) continue; // skip 1 - const extra = extensionFields.get(objectName); - const objectDef: AnyRec = extra - ? { ...declared, fields: [...recordsOf(declared.fields), ...extra] } - : declared; + const objectDef = objects.get(objectName); + if (!objectDef) continue; // skip 1 const misses = unknownImportMappingTargets(mapping.fieldMapping, objectDef); if (misses.length === 0) continue; // clean, or skip 2 diff --git a/packages/lint/src/validate-object-field-refs.ts b/packages/lint/src/validate-object-field-refs.ts index 58986956469..c18b23ed09e 100644 --- a/packages/lint/src/validate-object-field-refs.ts +++ b/packages/lint/src/validate-object-field-refs.ts @@ -211,7 +211,7 @@ import { describeFieldPathVerdict, - indexObjectGraph, + indexServedObjectGraph, isUnjudgeable, recordsOf, resolveFieldPath, @@ -466,7 +466,7 @@ export function validateObjectFieldRefs(stack: AnyRec): ObjectFieldRefFinding[] const findings: ObjectFieldRefFinding[] = []; if (!isRec(stack)) return findings; - const graph: ObjectGraph = indexObjectGraph(stack); + const graph: ObjectGraph = indexServedObjectGraph(stack); if (graph.size === 0) return findings; /** diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 0b58c005cdd..97f9e51e10d 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -125,7 +125,7 @@ import { sectionGroupRefs, type SectionGroupRef, } from './object-field-groups.js'; -import { indexObjectGraph, recordsOf, resolveFieldPath, suggestName, type ObjectGraph } from './object-graph.js'; +import { indexObjectGraph, objectsAsServed, recordsOf, resolveFieldPath, suggestName, type ObjectGraph } from './object-graph.js'; // [#23013] The one answer to "does this field hold several values?" — the // predicate storage, the aggregate answer and the generator already ask. import { isMultiValueField } from '@objectstack/spec/data'; @@ -604,9 +604,12 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const findings: PageFieldFinding[] = []; if (!stack || typeof stack !== 'object') return findings; - // object name → its declared field names. Built with `recordsOf` so BOTH - // `fields` shapes (array of `{name}` and name-keyed map) resolve. - const objectFields = indexObjectFields(stack); + // object name → its field names. Built with `recordsOf` so BOTH `fields` + // shapes (array of `{name}` and name-keyed map) resolve. [#30383] Over the + // objects as served, every `objectExtensions` field folded in, as the graph + // below: a binding naming a field a sibling package adds is served. + const served = { objects: objectsAsServed(stack).objects }; + const objectFields = indexObjectFields(served); // [#8340] The provenance index alongside the existence one — same keying, // asked on the path where the existence check stays silent. const unprovisionedAnchors = indexUnprovisionedAnchors(stack); @@ -614,7 +617,7 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const objectFieldGroups = indexObjectFieldGroups(stack); // [#23013] Field DEFINITIONS (type, `multiple`), for the one question a // name index cannot answer: is a counted field multi-valued? - const objectGraph = indexObjectGraph(stack); + const objectGraph = indexObjectGraph(served); const pages = recordsOf(stack.pages); for (let pi = 0; pi < pages.length; pi++) { diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index 65100b76fb7..268bc0edcc4 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -11,7 +11,7 @@ import { import { normalizeFilterOperator } from '@objectstack/spec/ui'; import { filterGroupRules, walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; -import { indexObjectGraph, recordsOf, resolveFieldPath, type ObjectGraph } from './object-graph.js'; +import { indexServedObjectGraph, recordsOf, resolveFieldPath, type ObjectGraph } from './object-graph.js'; /** * Build-time refusal of a bare dashboard date-range PRESET name authored as a @@ -743,7 +743,9 @@ export function validatePresetComparands( if (!stack || typeof stack !== 'object') return []; const out: PresetComparandFinding[] = []; - const graph = indexObjectGraph(stack); + // [#30383] Served, a field two packages tie on left out: which definition + // wins is registration order, so its type is not judged here. + const graph = indexServedObjectGraph(stack, { tied: 'drop' }); const datasets = new Map(); for (const dataset of recordsOf(stack.datasets)) { const name = strName(dataset.name); diff --git a/packages/lint/src/validate-related-list-refs.ts b/packages/lint/src/validate-related-list-refs.ts index 5499d1c398c..de7043f30f5 100644 --- a/packages/lint/src/validate-related-list-refs.ts +++ b/packages/lint/src/validate-related-list-refs.ts @@ -23,9 +23,15 @@ * * ## Which children are judged — the universe the caller hands over * - * The children are the stack's `objects` (map or array form) with the fields - * their `objectExtensions` add merged in by name, since an extension can be - * the one that adds the FK. + * The children are the stack's `objects` (map or array form) as the registry + * serves them, since an extension can be the one that adds the FK: every + * `objectExtensions` field folded in through the package's one fold + * (`objectsAsServed`, #30383). A field record is read by name in either + * `fields` shape. An object the stack does not declare is not a child here + * even when an extension targets it: its own declaration, and so the FKs it + * declares, may live in a package this stack does not carry (the per-package + * leg of `os build` holds one body's objects), so its extensions alone are not + * its field set, and it falls to the rule for an unknown child below. * * - A named child that IS in the stack but carries no such FK is refused on * every surface: that child's fields are known. @@ -58,7 +64,7 @@ */ import { fieldGroupBodyTableRefusal, type NamedInboundRelationship } from '@objectstack/spec/data'; -import { recordsOf } from './object-graph.js'; +import { objectsAsServed, recordsOf } from './object-graph.js'; /** A related-list entry that matches no inbound relationship of its object. */ export const RELATED_LIST_REF_UNKNOWN = 'related-list-ref-unknown'; @@ -123,22 +129,22 @@ export function validateRelatedListRefs(stack: AnyRec, options?: RelatedListRefO const objects = recordsOf(stack.objects); if (objects.length === 0) return findings; - // Child object name → its fields (declaration and extensions merged by name). - const fieldsByObject = new Map(); - for (const obj of objects) { - if (typeof obj.name !== 'string') continue; - fieldsByObject.set(obj.name, { ...fieldsByObject.get(obj.name), ...(isRec(obj.fields) ? obj.fields : {}) }); - } - for (const ext of recordsOf(stack.objectExtensions)) { - if (typeof ext.extend !== 'string') continue; - fieldsByObject.set(ext.extend, { ...fieldsByObject.get(ext.extend), ...(isRec(ext.fields) ? ext.fields : {}) }); + // Child object name → its fields by name, as served (see the header). + const fieldsByObject = new Map>(); + const merge = (child: string, fields: AnyRec[]): void => { + const byName = fieldsByObject.get(child) ?? new Map(); + for (const f of fields) if (typeof f.name === 'string') byName.set(f.name, f); + fieldsByObject.set(child, byName); + }; + for (const obj of objectsAsServed(stack).objects) { + if (typeof obj.name === 'string') merge(obj.name, recordsOf(obj.fields)); } // Parent name → its inbound relationships, in child then field order. const inbound = new Map(); for (const [child, fields] of fieldsByObject) { - for (const [field, def] of Object.entries(fields)) { - if (!isRec(def) || (def.type !== 'lookup' && def.type !== 'master_detail')) continue; + for (const [field, def] of fields) { + if (def.type !== 'lookup' && def.type !== 'master_detail') continue; if (typeof def.reference !== 'string' || AUDIT_FKS.has(field)) continue; const list = inbound.get(def.reference) ?? []; list.push({ child, field, def }); diff --git a/packages/lint/src/validate-report-runtime-filter.ts b/packages/lint/src/validate-report-runtime-filter.ts index 3b984fd59a8..8d90379f1e3 100644 --- a/packages/lint/src/validate-report-runtime-filter.ts +++ b/packages/lint/src/validate-report-runtime-filter.ts @@ -49,7 +49,7 @@ * question, not this card's. */ -import { indexObjectGraph, recordsOf } from './object-graph.js'; +import { indexServedObjectGraph, recordsOf } from './object-graph.js'; import type { AnyRec } from './page-walk.js'; import { indexResolvableDatasets, judgeDatasetFilterKeys } from './validate-widget-bindings.js'; @@ -84,7 +84,7 @@ export function validateReportRuntimeFilters(stack: AnyRec): ReportRuntimeFilter const reports = recordsOf(stack.reports); if (reports.length === 0) return findings; const datasets = indexResolvableDatasets(stack); - const graph = indexObjectGraph(stack); + const graph = indexServedObjectGraph(stack); const judge = (filter: unknown, path: string, dsName: string | undefined, where: string): void => { if (filter === undefined || filter === null) return; diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index e329b5b8a3c..5425e95a714 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -296,7 +296,7 @@ import { ExecutionContextSchema } from '@objectstack/spec/kernel'; import { RELATIONSHIP_FIELD_TYPES, describeFieldPathVerdict, - indexObjectGraph, + indexServedObjectGraph, isUnjudgeable, listNames, recordsOf, @@ -1904,14 +1904,20 @@ export function validateRlsPredicateEnforceability( const findings: RlsPredicateFinding[] = []; const cfg = (stack ?? {}) as AnyRec; - // [#16119] The object graph for the reference pass, built ONCE: `indexObjectGraph` + // [#16119] The object graph for the reference pass, built ONCE: `indexServedObjectGraph` // walks every object's whole field map, and a stack with N permission sets would // otherwise pay for that walk N times to answer the same question. It is the // SHARED index every field-existence rule in this package resolves through, so // the three skips (an object this stack does not define, an object with no // readable field map, registry-injected system columns) are the graph's and not - // re-derived here. - const graph: ObjectGraph = indexObjectGraph(cfg); + // re-derived here. [#30383] It indexes the objects as SERVED: the registry + // folds every `objectExtensions` field in before the RLS compiler sees the + // object, so a predicate naming a field a sibling package adds resolves. + // A field two packages write at the same deciding priority (`objectsAsServed`'s + // `tied`) is kept as the fold keeps it, the last writer: its list-holding and + // cross-class questions are answered on that definition, although which one + // the registry serves is its registration order. + const graph: ObjectGraph = indexServedObjectGraph(cfg); // [#25234] The declared membership keys every predicate below is judged // against: bound as lists, traced to their targets — see `## Membership keys`. const membership = membershipKeyContextOf(cfg, graph, options.membershipKeyOwners); diff --git a/packages/lint/src/validate-sharing-rule-enforceability.ts b/packages/lint/src/validate-sharing-rule-enforceability.ts index 3b457768095..6b468c4e50a 100644 --- a/packages/lint/src/validate-sharing-rule-enforceability.ts +++ b/packages/lint/src/validate-sharing-rule-enforceability.ts @@ -192,7 +192,7 @@ import { compileCelToFilter } from '@objectstack/formula'; import { referenceCarrierOf } from '@objectstack/spec/data'; -import { indexObjectGraph, recordsOf, type ObjectGraph } from './object-graph.js'; +import { indexObjectGraph, objectsAsServed, objectsWithoutTiedFields, recordsOf, type ObjectGraph } from './object-graph.js'; import { crossClassComparisons, crossClassVerdict, @@ -569,12 +569,27 @@ export function validateSharingRuleEnforceability(stack: unknown): SharingRuleEn const findings: SharingRuleEnforceabilityFinding[] = []; const cfg = (stack ?? {}) as AnyRec; + // [#30383] The objects as SERVED, every `objectExtensions` field folded in: + // the criteria query the driver judges runs against the registry's object, + // so a comparison with a field a sibling package adds is judged on that + // field's served definition. The fold moves only `fields`: the anchor arm + // reads the same `sharingModel`, and a `controlled_by_parent` anchor's master + // is the `master_detail` the served object carries, as the security posture + // rule's merge (#23210) reads it. + const served = objectsAsServed(cfg); const objectsByName = new Map(); - for (const obj of recordsOf(cfg.objects)) { + for (const obj of served.objects) { const name = str(obj.name); if (name) objectsByName.set(name, obj); } - // [#19886] The declared field map of a rule's ANCHOR, for the list-holding + // The comparison arms judge a field's TYPE, so a field two packages tie on + // is left out of their graph: which definition wins is registration order. + const judgedByName = new Map(); + for (const obj of objectsWithoutTiedFields(served)) { + const name = str(obj.name); + if (name) judgedByName.set(name, obj); + } + // [#19886] The served field map of a rule's ANCHOR, for the list-holding // arm — the only object a lowered sharing criterion can address (a // cross-object path does not lower). Built lazily, once per anchor, and only // for a condition that lowered, so a stack this arm never judges is indexed @@ -582,7 +597,7 @@ export function validateSharingRuleEnforceability(stack: unknown): SharingRuleEn // keeps its precedence. const graphs = new Map(); const anchorGraph = (object: string): ObjectGraph | null => { - const target = objectsByName.get(object); + const target = judgedByName.get(object); if (!target) return null; let graph = graphs.get(object); if (!graph) { diff --git a/packages/lint/src/validate-thank-you-record-fields.ts b/packages/lint/src/validate-thank-you-record-fields.ts index 014ba032cba..3e049365af8 100644 --- a/packages/lint/src/validate-thank-you-record-fields.ts +++ b/packages/lint/src/validate-thank-you-record-fields.ts @@ -57,7 +57,12 @@ import { RUNTIME_OWNED_FIELD_TYPES } from '@objectstack/spec/data'; import { anonymousFormObjectName, thankYouRecordFieldNames } from '@objectstack/spec/ui'; import { collectionEntries } from './collection-entries.js'; -import { indexObjectGraph, type GraphObject } from './object-graph.js'; +import { + indexObjectGraph, + objectExtensionsByTarget, + objectsAsServed, + type GraphObject, +} from './object-graph.js'; import { walkPageComponents } from './page-walk.js'; export const THANK_YOU_RECORD_FIELD = 'thank-you-record-field'; @@ -162,6 +167,31 @@ function refusalReason(object: GraphObject, name: string, admitted: ReadonlySet< return 'not a field the object declares'; } +/** + * [#30383] For a field two packages write at the priority that decides it + * (`objectsAsServed`'s `tied`), which definition the registry serves is the + * registration order, which a stack does not state. The panel is shown to an + * anonymous visitor, so this fails closed: such a field is admitted only when + * every definition at that priority is engine-generated, and is otherwise + * refused naming the first one that is not. + */ +function tiedFieldRefusal( + stack: AnyRec, + tied: ReadonlyMap>, + admitted: ReadonlySet, +): (objectName: string, name: string) => string | undefined { + if (tied.size === 0) return () => undefined; + const extensions = objectExtensionsByTarget(stack); + return (objectName, name) => { + if (!tied.get(objectName)?.has(name)) return undefined; + const writers = (extensions.get(objectName) ?? []).flatMap((ext) => + ext.fields.filter((f) => f.name === name).map((f) => ({ priority: ext.priority, type: f.type }))); + const deciding = Math.max(...writers.map((w) => w.priority)); + const refused = writers.find((w) => w.priority === deciding && !(typeof w.type === 'string' && admitted.has(w.type))); + return refused ? `a ${typeof refused.type === 'string' ? refused.type : 'untyped'} field` : undefined; + }; +} + /** * Refuse every name a `thank-you` panel interpolates that is not an * engine-generated field. Empty = clean. @@ -178,7 +208,10 @@ export function validateThankYouRecordFields( const findings: ThankYouRecordFieldFinding[] = []; const sites = panelSites(stack); if (sites.length === 0) return findings; - const graph = indexObjectGraph(stack); + // [#30383] The objects as served, every `objectExtensions` field folded in. + const served = objectsAsServed(stack); + const graph = indexObjectGraph({ objects: served.objects }); + const tiedRefusal = tiedFieldRefusal(stack, served.tied, admitted); for (const site of sites) { const names = thankYouRecordFieldNames(site.submitBehavior); @@ -186,7 +219,7 @@ export function validateThankYouRecordFields( const object = graph.get(site.objectName); if (!object) continue; for (const name of names) { - const reason = refusalReason(object, name, admitted); + const reason = refusalReason(object, name, admitted) ?? tiedRefusal(site.objectName, name); if (reason === undefined) continue; findings.push({ severity: 'error', diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index e653e768952..98d9c050b4e 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -9,6 +9,8 @@ import { indexObjectGraph, isUnjudgeable, joinablePrefixes, + objectsAsServed, + objectsWithoutTiedFields, packagesOf, recordsOf, resolveFieldPath, @@ -813,8 +815,14 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // (it can exceed 100%); rates must AVG. This is a dataset-level defect (it // does not depend on any widget), so it is checked once over every dataset // whose object's field types are known. Advisory — the page still renders. + // [#30383] Read over the objects as served, every `objectExtensions` field + // folded in, as the graph below is: the runtime aggregates the served field. + // A field two packages tie on is left out of this TYPE map: which definition + // wins is registration order. + const servedStack = objectsAsServed(stack); + const served = servedStack.objects; const objectFieldTypes = new Map>(); - for (const o of recordsOf(stack.objects)) { + for (const o of objectsWithoutTiedFields(servedStack)) { if (typeof o.name !== 'string') continue; const fm = new Map(); for (const f of recordsOf(o.fields)) { @@ -830,7 +838,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // once for the whole stack and shared by every widget, exactly as // `validate-dataset-references.ts` does one level down — the same seam, so // the two positions cannot drift into two accounts of one object graph. - const graph: ObjectGraph = indexObjectGraph(stack); + const graph: ObjectGraph = indexObjectGraph({ objects: served }); const datasetList = recordsOf(stack.datasets); for (let i = 0; i < datasetList.length; i++) { const ds = datasetList[i];