Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/30383-field-path-rules-extension-fields.md
Original file line number Diff line number Diff line change
@@ -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.
273 changes: 273 additions & 0 deletions packages/lint/src/object-graph.test.ts

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions packages/lint/src/object-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,3 +792,42 @@ export function listNames(names: Iterable<string>): 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<string, Set<string>> }): 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 });
}
6 changes: 4 additions & 2 deletions packages/lint/src/validate-dataset-measure-aggregates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ──
Expand Down
4 changes: 2 additions & 2 deletions packages/lint/src/validate-dataset-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
import { walkFilterFieldKeys } from './filter-walk.js';
import {
describeFieldPathVerdict,
indexObjectGraph,
indexServedObjectGraph,
isUnjudgeable,
joinablePrefixes,
recordsOf,
Expand Down Expand Up @@ -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}`;
Expand Down
9 changes: 6 additions & 3 deletions packages/lint/src/validate-field-consumers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 13 additions & 4 deletions packages/lint/src/validate-list-view-field-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ import {
describeFieldPathVerdict,
indexObjectGraph,
isUnjudgeable,
objectsAsServed,
objectsWithoutTiedFields,
recordsOf,
resolveFieldPath,
suggestName,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, Map<string, ReadonlySet<string>>>();
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<string, ReadonlySet<string>>();
Expand Down
33 changes: 21 additions & 12 deletions packages/lint/src/validate-mapping-target-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 11 additions & 35 deletions packages/lint/src/validate-mapping-target-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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
Expand Down Expand Up @@ -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<string, AnyRec[]> {
const byTarget = new Map<string, AnyRec[]>();
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
Expand All @@ -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<string, AnyRec>();
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
Expand Down
4 changes: 2 additions & 2 deletions packages/lint/src/validate-object-field-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@

import {
describeFieldPathVerdict,
indexObjectGraph,
indexServedObjectGraph,
isUnjudgeable,
recordsOf,
resolveFieldPath,
Expand Down Expand Up @@ -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;

/**
Expand Down
13 changes: 8 additions & 5 deletions packages/lint/src/validate-page-field-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -604,17 +604,20 @@ 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);
// [#13855] object name → its declared field-group keys, for `section.group`.
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++) {
Expand Down
6 changes: 4 additions & 2 deletions packages/lint/src/validate-preset-comparands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, AnyRec>();
for (const dataset of recordsOf(stack.datasets)) {
const name = strName(dataset.name);
Expand Down
Loading
Loading