From fdf882b9db554eafec0b45c55e2140942c294b47 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:17:42 +0000 Subject: [PATCH 1/5] fix(lint): field-path rules resolve through the served object fold Twelve field-path rules indexed declared objects only, and two rules folded objectExtensions through private readers that differed from the registry. All now read the one shared fold in object-graph.ts (objectsAsServed / objectExtensionsByTarget): a view, report, dataset, page or widget naming a field a sibling package adds through objectExtensions resolves as it does at runtime. Adds indexServedObjectGraph, the graph over objectsAsServed. Claude-Session: https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG Co-authored-by: Claude --- packages/lint/src/object-graph.ts | 11 +++++ .../validate-dataset-measure-aggregates.ts | 4 +- .../lint/src/validate-dataset-references.ts | 4 +- packages/lint/src/validate-field-consumers.ts | 9 ++-- .../lint/src/validate-list-view-field-refs.ts | 12 +++-- .../src/validate-mapping-target-fields.ts | 46 +++++-------------- .../lint/src/validate-object-field-refs.ts | 4 +- .../lint/src/validate-page-field-bindings.ts | 13 ++++-- .../lint/src/validate-preset-comparands.ts | 4 +- .../lint/src/validate-related-list-refs.ts | 35 ++++++++------ .../src/validate-report-runtime-filter.ts | 4 +- .../validate-rls-predicate-enforceability.ts | 10 ++-- .../validate-sharing-rule-enforceability.ts | 13 ++++-- .../src/validate-thank-you-record-fields.ts | 4 +- packages/lint/src/validate-widget-bindings.ts | 8 +++- 15 files changed, 100 insertions(+), 81 deletions(-) diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index 54c71089aec..6701b63b281 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -500,6 +500,17 @@ export function objectsAsServed(stack: unknown): { objects: AnyRec[]; tied: Map< return { objects: served, tied }; } +/** + * [#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. + */ +export function indexServedObjectGraph(stack: unknown): ObjectGraph { + return indexObjectGraph({ objects: objectsAsServed(stack).objects }); +} + /** What a path resolved to, or precisely how it failed. */ export type FieldPathVerdict = /** diff --git a/packages/lint/src/validate-dataset-measure-aggregates.ts b/packages/lint/src/validate-dataset-measure-aggregates.ts index 62e68da86a5..35a2840732e 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,7 @@ 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); + const graph: ObjectGraph = indexServedObjectGraph(stack); 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..0260e6ea2ec 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -428,6 +428,7 @@ import { describeFieldPathVerdict, indexObjectGraph, isUnjudgeable, + objectsAsServed, recordsOf, resolveFieldPath, suggestName, @@ -959,7 +960,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 served = objectsAsServed(stack).objects; + const graph: ObjectGraph = indexObjectGraph({ objects: served }); const scope = options?.packageScope; // [#24668] The whole collection, stated — and only without a closure, which // is the narrower set and wins. @@ -1058,11 +1064,11 @@ 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 + * the same served objects the graph indexes. A field with no non-empty * inline `options` array is absent: its lanes are not judged. */ const optionValues = new Map>>(); - for (const obj of recordsOf(stack.objects)) { + for (const obj of served) { const objName = strName(obj.name); if (!objName) continue; const byField = new Map>(); 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..7188470a5b7 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,7 @@ export function validatePresetComparands( if (!stack || typeof stack !== 'object') return []; const out: PresetComparandFinding[] = []; - const graph = indexObjectGraph(stack); + const graph = indexServedObjectGraph(stack); 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..a1fd41f1151 100644 --- a/packages/lint/src/validate-related-list-refs.ts +++ b/packages/lint/src/validate-related-list-refs.ts @@ -23,9 +23,12 @@ * * ## 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), and a child only an extension reaches (another + * package's object) carrying its extensions' fields alone. A field record is + * read by name in either `fields` shape. * * - 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 +61,7 @@ */ import { fieldGroupBodyTableRefusal, type NamedInboundRelationship } from '@objectstack/spec/data'; -import { recordsOf } from './object-graph.js'; +import { objectExtensionsByTarget, 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 +126,26 @@ 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 : {}) }); + // 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)); } - 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 : {}) }); + for (const [target, extensions] of objectExtensionsByTarget(stack)) { + if (fieldsByObject.has(target)) continue; // folded in above + for (const ext of extensions) merge(target, ext.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..86ab14341f0 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,16 @@ 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. + 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..4c3ea78d9bf 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, recordsOf, type ObjectGraph } from './object-graph.js'; import { crossClassComparisons, crossClassVerdict, @@ -569,12 +569,19 @@ 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 objectsByName = new Map(); - for (const obj of recordsOf(cfg.objects)) { + for (const obj of objectsAsServed(cfg).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 + // [#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 diff --git a/packages/lint/src/validate-thank-you-record-fields.ts b/packages/lint/src/validate-thank-you-record-fields.ts index 014ba032cba..df7d98965d3 100644 --- a/packages/lint/src/validate-thank-you-record-fields.ts +++ b/packages/lint/src/validate-thank-you-record-fields.ts @@ -57,7 +57,7 @@ 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 { indexServedObjectGraph, type GraphObject } from './object-graph.js'; import { walkPageComponents } from './page-walk.js'; export const THANK_YOU_RECORD_FIELD = 'thank-you-record-field'; @@ -178,7 +178,7 @@ export function validateThankYouRecordFields( const findings: ThankYouRecordFieldFinding[] = []; const sites = panelSites(stack); if (sites.length === 0) return findings; - const graph = indexObjectGraph(stack); + const graph = indexServedObjectGraph(stack); for (const site of sites) { const names = thankYouRecordFieldNames(site.submitBehavior); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index e653e768952..313dbb9620d 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -9,6 +9,7 @@ import { indexObjectGraph, isUnjudgeable, joinablePrefixes, + objectsAsServed, packagesOf, recordsOf, resolveFieldPath, @@ -813,8 +814,11 @@ 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. + const served = objectsAsServed(stack).objects; const objectFieldTypes = new Map>(); - for (const o of recordsOf(stack.objects)) { + for (const o of served) { if (typeof o.name !== 'string') continue; const fm = new Map(); for (const f of recordsOf(o.fields)) { @@ -830,7 +834,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]; From e27387e27c0a40f723c1c31bb08d1570a37838e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:31:58 +0000 Subject: [PATCH 2/5] test(lint): pin every field-path rule on extension-added fields One artifact for all fourteen rules: a module package adds fields to the app package's object. Each existence rule passes the extension-added field, refuses it without the extension, and still refuses a field nothing adds under the same rule id; the type arms judge the served definition; the two retired readers keep one entry per package per target. Adds the changeset. Claude-Session: https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG Co-authored-by: Claude --- ...30383-field-path-rules-extension-fields.md | 11 ++ packages/lint/src/object-graph.test.ts | 159 ++++++++++++++++++ .../validate-mapping-target-fields.test.ts | 33 ++-- 3 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 .changeset/30383-field-path-rules-extension-fields.md 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..aa7bcd78cc9 --- /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. diff --git a/packages/lint/src/object-graph.test.ts b/packages/lint/src/object-graph.test.ts index 99fd9f185a2..af3ed62e143 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, 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 { 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,148 @@ 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 }] }], + })], + ]; + 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('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: 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]); + }); +}); 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. From 31eef37245cb836dab4005a16827833aaee31c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:26:31 +0000 Subject: [PATCH 3/5] fix(lint): related lists leave an undeclared child unjudged; type arms skip tied fields Contract review r1 on the served-fold PR: - validate-related-list-refs no longer builds a child from extensions alone: on the per-package leg a child another body owns read as having only a sibling's extension fields, a false related-list-ref-unknown. - A field two packages write at the deciding priority is served, but which definition wins is registration order. The type arms (measure aggregates, preset comparands, sharing comparisons, widget field types, kanban option values) leave it unjudged; the thank-you panel fails closed on it. - Pins: the per-package leg for every existence rule, byte-equal refusals against a declared-only control, the related-list per-package case, ties. Claude-Session: https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG Co-authored-by: Claude --- packages/lint/src/object-graph.test.ts | 84 +++++++++++++++++++ packages/lint/src/object-graph.ts | 47 ++++++++--- .../validate-dataset-measure-aggregates.ts | 4 +- .../lint/src/validate-list-view-field-refs.ts | 11 ++- .../lint/src/validate-preset-comparands.ts | 4 +- .../lint/src/validate-related-list-refs.ts | 15 ++-- .../validate-sharing-rule-enforceability.ts | 14 +++- .../src/validate-thank-you-record-fields.ts | 39 ++++++++- packages/lint/src/validate-widget-bindings.ts | 8 +- 9 files changed, 193 insertions(+), 33 deletions(-) diff --git a/packages/lint/src/object-graph.test.ts b/packages/lint/src/object-graph.test.ts index af3ed62e143..cbbdc81be76 100644 --- a/packages/lint/src/object-graph.test.ts +++ b/packages/lint/src/object-graph.test.ts @@ -624,6 +624,13 @@ describe('[#30383] the field-path rules read the objects as served', () => { 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. @@ -632,6 +639,21 @@ describe('[#30383] the field-path rules read the objects as served', () => { 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]); @@ -667,6 +689,17 @@ describe('[#30383] the field-path rules read the objects as served', () => { 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([]); @@ -690,4 +723,55 @@ describe('[#30383] the field-path rules read the objects as served', () => { 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 }] } }, + ], + }); + const numberOrText = (extra: Rec) => ({ ...tie({ score: { type: 'number' } }, { score: { type: 'text' } }), ...extra }); + const dateOrText = (extra: Rec) => ({ ...tie({ closed_on: { type: 'date' } }, { closed_on: { type: 'text' } }), ...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 the type arms', () => { + 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]); + expect(ids(validatePresetComparands, dateOrText(widget({ closed_on: 'last_30_days' })))).toEqual([]); + const rule = { sharingRules: [{ name: 'sr', object: 'xs_deal', condition: 'record.closed_on == record.name', sharedWith: { type: 'role', value: 'r' }, accessLevel: 'read' }] }; + expect(ids(validateSharingRuleEnforceability, dateOrText(rule))).toEqual([]); + }); + + 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 6701b63b281..6524183b2d9 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -500,17 +500,6 @@ export function objectsAsServed(stack: unknown): { objects: AnyRec[]; tied: Map< return { objects: served, tied }; } -/** - * [#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. - */ -export function indexServedObjectGraph(stack: unknown): ObjectGraph { - return indexObjectGraph({ objects: objectsAsServed(stack).objects }); -} - /** What a path resolved to, or precisely how it failed. */ export type FieldPathVerdict = /** @@ -803,3 +792,39 @@ export function listNames(names: Iterable): string { const arr = [...names].sort(); return arr.length > 0 ? arr.join(', ') : '(none)'; } + +// [#30383] Kept out of the fold's section on purpose: `validate-security-posture.test.ts` +// tables every receiver read in that section, and 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 35a2840732e..753ac03e22f 100644 --- a/packages/lint/src/validate-dataset-measure-aggregates.ts +++ b/packages/lint/src/validate-dataset-measure-aggregates.ts @@ -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 = indexServedObjectGraph(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-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index 0260e6ea2ec..c6dcf04111f 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -429,6 +429,7 @@ import { indexObjectGraph, isUnjudgeable, objectsAsServed, + objectsWithoutTiedFields, recordsOf, resolveFieldPath, suggestName, @@ -964,8 +965,8 @@ export function validateListViewFieldRefs( // 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 served = objectsAsServed(stack).objects; - const graph: ObjectGraph = indexObjectGraph({ objects: served }); + 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. @@ -1065,10 +1066,12 @@ export function validateListViewFieldRefs( /** * [#28018] The inline option values each object's fields declare, read from * the same served objects the graph indexes. A field with no non-empty - * inline `options` array is absent: its lanes are not judged. + * 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 served) { + 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-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index 7188470a5b7..268bc0edcc4 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -743,7 +743,9 @@ export function validatePresetComparands( if (!stack || typeof stack !== 'object') return []; const out: PresetComparandFinding[] = []; - const graph = indexServedObjectGraph(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 a1fd41f1151..de7043f30f5 100644 --- a/packages/lint/src/validate-related-list-refs.ts +++ b/packages/lint/src/validate-related-list-refs.ts @@ -26,9 +26,12 @@ * 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), and a child only an extension reaches (another - * package's object) carrying its extensions' fields alone. A field record is - * read by name in either `fields` shape. + * (`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. @@ -61,7 +64,7 @@ */ import { fieldGroupBodyTableRefusal, type NamedInboundRelationship } from '@objectstack/spec/data'; -import { objectExtensionsByTarget, objectsAsServed, 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'; @@ -136,10 +139,6 @@ export function validateRelatedListRefs(stack: AnyRec, options?: RelatedListRefO for (const obj of objectsAsServed(stack).objects) { if (typeof obj.name === 'string') merge(obj.name, recordsOf(obj.fields)); } - for (const [target, extensions] of objectExtensionsByTarget(stack)) { - if (fieldsByObject.has(target)) continue; // folded in above - for (const ext of extensions) merge(target, ext.fields); - } // Parent name → its inbound relationships, in child then field order. const inbound = new Map(); diff --git a/packages/lint/src/validate-sharing-rule-enforceability.ts b/packages/lint/src/validate-sharing-rule-enforceability.ts index 4c3ea78d9bf..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, objectsAsServed, recordsOf, type ObjectGraph } from './object-graph.js'; +import { indexObjectGraph, objectsAsServed, objectsWithoutTiedFields, recordsOf, type ObjectGraph } from './object-graph.js'; import { crossClassComparisons, crossClassVerdict, @@ -576,11 +576,19 @@ export function validateSharingRuleEnforceability(stack: unknown): SharingRuleEn // 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 objectsAsServed(cfg).objects) { + for (const obj of served.objects) { const name = str(obj.name); if (name) objectsByName.set(name, obj); } + // 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 @@ -589,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 df7d98965d3..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 { indexServedObjectGraph, 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 = indexServedObjectGraph(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 313dbb9620d..98d9c050b4e 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -10,6 +10,7 @@ import { isUnjudgeable, joinablePrefixes, objectsAsServed, + objectsWithoutTiedFields, packagesOf, recordsOf, resolveFieldPath, @@ -816,9 +817,12 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // 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. - const served = objectsAsServed(stack).objects; + // 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 served) { + for (const o of objectsWithoutTiedFields(servedStack)) { if (typeof o.name !== 'string') continue; const fm = new Map(); for (const f of recordsOf(o.fields)) { From a7e081935144d36f0b0c4094061e04a8dd511ed5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 11:31:14 +0000 Subject: [PATCH 4/5] docs(changeset): say how the field-path rules treat a tied extension field Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG --- .changeset/30383-field-path-rules-extension-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30383-field-path-rules-extension-fields.md b/.changeset/30383-field-path-rules-extension-fields.md index aa7bcd78cc9..0a2e6e5ed76 100644 --- a/.changeset/30383-field-path-rules-extension-fields.md +++ b/.changeset/30383-field-path-rules-extension-fields.md @@ -8,4 +8,4 @@ Clause-②: yes (widening: field-path rules accept fields a sibling package adds 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. +- **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. From 806dd06b0fe3f915f32a7b022d5f505166a0169d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:15:17 +0000 Subject: [PATCH 5/5] test(lint): pin each tie-dropping type arm on its own Each tied pair now lists the definition the arm would refuse last, which is the one the fold keeps, so every arm that read it instead of dropping the tie goes red. Adds the widget field-type map and the kanban option map, and says in the RLS rule and above the helpers how a tie is read. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG --- packages/lint/src/object-graph.test.ts | 42 ++++++++++++++++--- packages/lint/src/object-graph.ts | 7 +++- .../validate-rls-predicate-enforceability.ts | 4 ++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/lint/src/object-graph.test.ts b/packages/lint/src/object-graph.test.ts index cbbdc81be76..aaafbf175cb 100644 --- a/packages/lint/src/object-graph.test.ts +++ b/packages/lint/src/object-graph.test.ts @@ -23,7 +23,7 @@ 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, validateListViewFieldRefs } from './validate-list-view-field-refs.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'; @@ -33,7 +33,7 @@ import { REPORT_FILTER_FIELD_UNKNOWN, validateReportRuntimeFilters } from './val 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 { WIDGET_FILTER_FIELD_UNKNOWN, validateWidgetBindings } from './validate-widget-bindings.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'; @@ -737,22 +737,52 @@ describe('[#30383] the field-path rules read the objects as served', () => { { 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 dateOrText = (extra: Rec) => ({ ...tie({ closed_on: { type: 'date' } }, { closed_on: { 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 the type arms', () => { + 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]); - expect(ids(validatePresetComparands, dateOrText(widget({ closed_on: 'last_30_days' })))).toEqual([]); + }); + + 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, dateOrText(rule))).toEqual([]); + 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', () => { diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index 6524183b2d9..a3ff9236a8c 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -793,8 +793,11 @@ export function listNames(names: Iterable): string { return arr.length > 0 ? arr.join(', ') : '(none)'; } -// [#30383] Kept out of the fold's section on purpose: `validate-security-posture.test.ts` -// tables every receiver read in that section, and these two read only the fold's output. +// [#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 diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 86ab14341f0..5425e95a714 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -1913,6 +1913,10 @@ export function validateRlsPredicateEnforceability( // 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`.