Skip to content

Commit e760204

Browse files
fix(lint): field-path rules judge an object as served, so a field an objectExtensions entry adds is not refused (#30383) (#30432)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB3fsAozd9DYHnPe5HXgoWeN)_ Fixes #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) Skills-docs: none owed — no skill, eval or doc teaches that a view, report, dataset, page, widget, mapping, related list or row policy may not name an extension field (`git grep -c -E "dataset-field-unknown|list-view-field-unknown|rls-predicate-unknown-field|related-list-ref-unknown|mapping-target-field-unknown|page-field-unknown|widget-filter-field-unknown" -- skills content/docs ':!content/docs/releases'`: 1 hit, `content/docs/deployment/validating-metadata.mdx:267`, which names no extension; `skills/objectstack-data/rules/security.md:156` refuses a membership key on an extension, a rule this PR does not touch) Prior rulings read: objectextensions,indexobjectgraph,objectsasserved → 2 hits; ADR-0029 D3, ADR-0146 D10; thread: none; repo: objectstack-ai/objectstack ## 维护者速读 - 改了什么:卡上 12 条字段路径 lint 规则(数据集、报表筛选、仪表盘部件、列表视图、对象自身字段列表、页面绑定、预设比较值、RLS、共享规则、感谢页、度量聚合、字段消费者)和 2 个私有扩展读取器(相关列表、导入映射),全部改读 #30359 落地的同一份共享折叠:对象按注册表实际提供的样子判断,即声明字段加上同一制品中任何包通过 `objectExtensions` 加的字段。 - 为什么改:多包制品(HotCRM 的模块包给 app 包的对象加字段)里,视图、报表、数据集、页面、部件只要引用扩展字段就被 lint 判为未知字段并拒绝,而运行时明明提供这个字段。 - 风险与回滚:只放宽拒绝面;真不存在的字段照旧报原规则 id、级别、消息(逐字节钉住)。按类型判断的规则现在会按扩展字段的实际类型判断;两个包在同一优先级写同一字段时,胜者取决于注册顺序,这些类型判断对该字段不下结论,感谢页则只在所有写法都是引擎生成类型时才放行。相关列表对本栈未声明的对象不当作子对象判断(逐包检查时它的声明在别的包里)。showcase 全量实测 14 条规则前后发现完全一致。回滚即还原本 PR。 - 先看:`packages/lint/src/object-graph.ts` 文件末尾新增的 `indexServedObjectGraph` 与 `objectsWithoutTiedFields`;`validate-related-list-refs.ts` 与 `validate-mapping-target-fields.ts` 两个私有读取器的删除;`object-graph.test.ts` 末尾的 `[#30383]` describe。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/object-graph.test.ts`,预期 89 例全绿(其中 42 例为本 PR 新增)。 - 需要你做的:无,席位落地。 ## What changes - **`object-graph.ts`, two helpers at the end of the file:** `indexServedObjectGraph(stack, { tied })`, `indexObjectGraph` over `objectsAsServed(stack).objects`; and `objectsWithoutTiedFields(served)`, the served objects less every field in `served.tied`. Neither is exported from the package entry. They sit below the fold's section on purpose: `foldCode()` in `validate-security-posture.test.ts` (:1656-1665) appends `object-graph.ts`'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. The shared fold (`objectExtensionsByTarget`, `objectsAsServed`) itself is unchanged. - **Twelve rules now resolve through the served objects** (table below). Rules that build a second index beside the graph (list-view option values, widget field types, page field names) build it from the same served objects, so the two indexes of one rule cannot disagree. - **Tied fields** (`objectsAsServed(...).tied`: two packages write one field at the deciding priority). The registry registers packages in dependency-topological order (`resolveArtifactPackageOrder`), not `packages[]` order, so which definition is served cannot be read off the stack. Every rule still accepts the tied field's name. The type arms leave it unjudged: measure aggregates and preset comparands index `{ tied: 'drop' }`, the sharing rule's condition graph and the widget field-type map and kanban option map are built from `objectsWithoutTiedFields`. The thank-you rule fails closed instead: a tied field passes only when every writer at the deciding priority has an engine-generated type. - **Two private readers retired onto the shared fold:** - `validate-related-list-refs.ts`: its fold read the stack's top-level `objectExtensions` only, and only the map form of `fields`. It now reads `objectsAsServed` for the children this stack declares, every field read by name in either shape. An object this stack does not declare is not a child even when an extension targets it: on the per-package leg its declaration and foreign keys may live in a package the leg does not carry, so it falls to the unknown-child rule as before (header says so). - `validate-mapping-target-fields.ts`: `extensionFieldsByTarget` (top level plus every body, every entry kept) is deleted; the object is judged as served. The ruled refusal of a present non-array `packages` (ruling A on #15293) stays, asked through `packagesOf` once, since the fold reads such a value as absent. - **`validate-field-consumers.ts` header**: extension-added fields are still not judged themselves, but a dataset or cube path through an extension-added relationship now credits the declared field it reaches. - **Tests**: one top-level describe in `object-graph.test.ts`, the fold's harness (42 tests): for each existence rule an extension-added field passes, the same stack without the extension refuses, and a field nothing adds is refused with byte-equal severity, `where`, path and message to a declared-only control; the per-package leg (`packageBodyAsStack`'s shape) for the existence rules and for related lists; a nested describe for a field two packages tie on, one test per tie-dropping arm, each tied pair listing last the definition the arm would refuse (the one the fold keeps), with a single-writer control of that definition refused; `validate-mapping-target-fields.test.ts`'s extension fixture rewritten to the two shapes the registry reads (see Acceptance notes). - `.changeset/30383-field-path-rules-extension-fields.md`: `@objectstack/lint` patch. ### Per rule All on one artifact: an app package declares `xs_deal`, a module package extends it with `region`, `score`, `closed_on`, `ref_no`, `partner`, and extends `xs_note` with a lookup to `xs_deal`. | Rule | Moved or kept | Why | Pin | | --- | --- | --- | --- | | `validate-dataset-references.ts` | moved (`indexServedObjectGraph`) | the dataset compiler queries the served object | `ref_no` and `partner.tier` pass; without the extension `dataset-field-unknown`; `nowhere`, `partner.nowhere` still `dataset-field-unknown` | | `validate-report-runtime-filter.ts` | moved | same compiler | `ref_no` passes, refused without the extension; `nowhere` still `report-filter-field-unknown` | | `validate-widget-bindings.ts` | moved (graph, field-type map without ties) | same | `ref_no` passes, refused without; `nowhere` still `widget-filter-field-unknown`; SUM over a `number`/`percent` tie is not `measure-aggregate-incoherent`, over `percent` alone it is | | `validate-list-view-field-refs.ts` | moved (graph, kanban option map without ties) | the list door queries the served object | `ref_no` passes, refused without; `nowhere` still `list-view-field-unknown`; a lane `b` on a select tied between options `a,b` and `a,c` is not judged, on `a,c` alone it is `list-view-kanban-lane-unknown` | | `validate-object-field-refs.ts` | moved | the record page reads the served object | `ref_no` passes, refused without; `nowhere` still `object-field-ref-unknown` | | `validate-page-field-bindings.ts` | moved, last (graph and field-name index) | the renderer binds the served object | `ref_no` passes, refused without; `nowhere` still `page-field-unknown` | | `validate-rls-predicate-enforceability.ts` | moved | the RLS compiler compiles against the served object; the maintainer's hotcrm#2315 ruling gives a module's extension fields their access in the app package's permission sets | `ref_no` passes, refused without; `nowhere` still `rls-predicate-unknown-field` | | `validate-thank-you-record-fields.ts` | moved, ties fail closed | the panel renders the inserted served record | an extension `autonumber` passes, refused without; `nowhere` still `thank-you-record-field`; a tie between `autonumber` and `text` is refused | | `validate-mapping-target-fields.ts` | moved, private reader retired | the import door writes the served object | `ref_no` passes, refused without; `nowhere` still `mapping-target-field-unknown`; one entry per package per target | | `validate-related-list-refs.ts` | moved, private reader retired | the record page derives related lists from served fields | an FK a sibling package adds passes, bare and as `object.field`; refused without; `xs_note.nowhere` still `related-list-ref-unknown`; one entry per package per target; on the per-package leg a body extending another package's undeclared object raises nothing | | `validate-dataset-measure-aggregates.ts` | moved (type arm, ties dropped) | judges the served type | SUM over extension `score` passes, over extension `region` is `measure-aggregate-field-type-refused`; `nowhere` stays the existence rule's; a tied `score` is not judged | | `validate-preset-comparands.ts` | moved (type arm, ties dropped) | judges the served type | `closed_on: 'last_30_days'` on an extension date is `filter-preset-comparand`; on extension text it passes | | `validate-sharing-rule-enforceability.ts` | moved (type arm without ties, anchor served) | the driver judges the criteria query against the served object; a `controlled_by_parent` anchor's master is read as the security posture rule's merge (#23210) reads it | `record.region == record.name` passes; `record.closed_on == record.name` is `sharing-rule-unlowerable-condition` | | `validate-field-consumers.ts` | moved for resolution; extension fields still not judged | crediting a path the runtime serves | `partner.tier` credits `xs_account.tier`; without that path `tier` is `field-no-consumers`; no finding on any extension-added field | No rule stays on declared fields. The two security rules were weighed: the RLS rule's only verdict on an extension field was a false `rls-predicate-unknown-field`, and the sharing rule's type arm judges what the driver judges at query time, so neither has a security reason to read the declared anchor. ## Why The runtime serves each object with every body's `objectExtensions` folded in, but these rules indexed `stack.objects` only, so on a multi-package artifact (HotCRM, hotcrm#2315: a module package adds fields to an app package's object) a view, report, dataset, page or widget naming an extension-added field was refused as unknown. #30303 (PR #30359) added the shared fold and moved the FLS-key check onto it; this PR moves the rest of the census onto the same fold. ## Verification All at head `806dd06b`, under `os-verify-lock`, `NODE_OPTIONS=--max-old-space-size=4096`: - `pnpm --filter @objectstack/lint test && pnpm --filter @objectstack/lint typecheck`: Test Files 194 passed (194), Tests 8063 passed (8063); `check:test-typecheck: OK`; VERDICT command-exit 0. `object-graph.test.ts`: 89 passed (89). - `npx eslint --no-inline-config` over the 17 changed `.ts` files: exit 0. - Before/after on the showcase (`examples/app-showcase`, which carries top-level `objectExtensions`), measured at `e27387e2`: each of the 14 rules run on the loaded config with the `origin/main` copy and with that head. Every rule's finding set (rule, path, where, message) is identical, 49 `field-no-consumers` warnings and 0 of every other id on both sides. Probe files deleted. The rework narrows only what that head accepted (an undeclared related-list child, a tied field's type), and the showcase has neither. <details><summary>Reverse verification (restored to the HEAD blob each time)</summary> - Round 1, `scripts/ablation-replace.mjs`: `indexServedObjectGraph`'s body back to `indexObjectGraph(stack)` (anchor 1 → 0): `object-graph.test.ts` went **9 failed | 54 passed (63)**, exactly the pins of the eight rules routed through the helper. The other six rules read `objectsAsServed` directly; each of their pins asserts the same stack without the extension still refuses, so none is vacuous. - Rework, related lists (at `31eef372`): `validate-related-list-refs.ts` set back to its `e27387e2` blob: the new per-package-leg pin goes red (**1 failed | 84 passed (85)**, a false `related-list-ref-unknown` on the undeclared target); restored. - Rework, ties: `objectsWithoutTiedFields`'s first line made to return `served.objects` unconditionally: **5 failed | 84 passed (89)**, one per arm: measure aggregates, preset comparands, sharing-rule criteria, the widget field-type map, the kanban option map. The thank-you pin stays green, as it should: that rule reads `tied` itself and fails closed. Restored, `git diff HEAD` empty. (`ablation-replace.mjs` refuses a replacement that keeps its anchor, so this one was applied and restored by hand, the blob checked equal to HEAD after.) - The lint tests import `src`, so no dist sat between mutation and run. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `806dd06b`: 59 derived families, the same set as at `a7e08193`. Each run serially with `NODE_OPTIONS=--max-old-space-size=4096`; record `/mnt/project-files/issue-30383/gates-ran.list`, every line `exit 0` on the first pass, `check-changeset-no-major` included. - `dispatch-gates --ran`: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED", exit 0. - No `!` in the title and no `(narrowing)` arm: no full dogfood run. CI owns the rest of the farm. </details> ## Acceptance notes - **Measured differences of the two retired readers.** (1) `validate-mapping-target-fields.ts` read the top-level `objectExtensions` beside `packages[]`; the registry reads only the bodies when an artifact carries `packages[]` (its top level repeats them). The old test fixture put `sla_tier` at the top level only, beside a `packages[]`, a shape no CLI leg produces (the per-package leg hands the body's own entries in its `packages[]` entry too); it is rewritten to the two shapes the registry reads. (2) Both readers kept every entry; the fold keeps one per package per target, so a field only a package's earlier, replaced entry adds is now refused, as the registry serves it (pinned). (3) The related-list reader read only the map form of `fields`; the array form is now read too. (4) The related-list reader made an undeclared object reached by a top-level extension a judged child; that is dropped, so such an object falls to the unknown-child rule, as it does for any undeclared object. On the showcase none of these changes a finding. - **Tied fields inside existence rules.** The existence rules accept a tied field's name. Where an existence rule also asks a type question of the graph (RLS list-holding and cross-class checks, a list view's dotted head, a page count's multi-value check, a dataset hop), it reads the definition the fold kept, the one `objectsAsServed` returns; only the type arms named above drop ties. Narrowing those too would need the graph to carry a tie per field, a change to the shared index this card leaves alone. - **Census, wider than the card** (by reading; not reproduced per rule, ⛔ not changed here; carried to the seat): - `validate-translation-references.ts` keeps a third private `objectExtensionsByTarget` (top level plus every body, every entry kept). It also folds validation rules, which the shared fold does not carry, so it was not moved. - Field-name indexes outside `indexObjectGraph` still read declared objects only: `validate-expressions.ts` (`buildFieldIndex`, the `record.FIELD` reads of validations, formulas, flows, hooks and sharing-rule conditions), `indexObjectFields` in `validate-react-page-props.ts`, `validate-flow-node-writes.ts` and `validate-action-body-writes.ts`, and the own copy in `validate-hook-body-writes.ts`. Open PR #30270 moves page sorts to `validate-sortable-fields.ts`, which at its head reads declared objects only (`recordsOf(stack.objects)`, line 508); carried with the others. - `defineStack`'s twin, `collectRelatedListNameErrors` in `packages/spec/src/stack.zod.ts`, folds every entry additively; lint is now the stricter of the two on the replaced-entry case only. - `validate-page-field-bindings.ts` was moved last; open draft PR #30270 edits the same file in hunks this PR does not touch. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint over the changed files: exit 0; the repo-wide run is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block excepted, per project convention). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzg3UmwhpAtL969c7YG --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f506b79 commit e760204

18 files changed

Lines changed: 492 additions & 97 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
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)
6+
7+
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)
8+
Metadata-protocol: none (lint judgements, no authorable key)
9+
10+
- **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.
11+
- **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.

‎packages/lint/src/object-graph.test.ts‎

Lines changed: 273 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/lint/src/object-graph.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,3 +792,42 @@ export function listNames(names: Iterable<string>): string {
792792
const arr = [...names].sort();
793793
return arr.length > 0 ? arr.join(', ') : '(none)';
794794
}
795+
796+
// [#30383] Kept below the fold's section on purpose: `foldCode()` in
797+
// `validate-security-posture.test.ts` appends this file's text from
798+
// `export interface VisibleObjectExtension` to `/** What a path resolved to` to
799+
// the posture rule's code and holds every receiver read there to its tables;
800+
// these two read only the fold's output.
801+
802+
/**
803+
* [#30383] The served objects with every `tied` field taken out: what a rule
804+
* may judge a field's DEFINITION on. A field two packages write at the
805+
* priority that decides it is served, but which definition wins is the
806+
* registration order, and the registry registers an artifact's packages in
807+
* dependency-topological order (`resolveArtifactPackageOrder`), not in the
808+
* `packages[]` order the fold breaks ties by. So a type arm leaves a tied field
809+
* unjudged, as the membership-key rule does, and a rule that only asks whether
810+
* the name is served keeps it ({@link indexServedObjectGraph}'s default).
811+
*/
812+
export function objectsWithoutTiedFields(served: { objects: AnyRec[]; tied: Map<string, Set<string>> }): AnyRec[] {
813+
if (served.tied.size === 0) return served.objects;
814+
return served.objects.map((obj) => {
815+
const name = strName(obj.name);
816+
const tiedKeys = name ? served.tied.get(name) : undefined;
817+
if (!tiedKeys) return obj;
818+
return { ...obj, fields: recordsOf(obj.fields).filter((f) => !tiedKeys.has(strName(f.name) ?? '')) };
819+
});
820+
}
821+
822+
/**
823+
* [#30383] {@link indexObjectGraph} over {@link objectsAsServed}: the graph of
824+
* the objects the registry serves, every `objectExtensions` field folded in.
825+
* The field-path rules of this package resolve through it, so a path naming a
826+
* field a sibling package adds resolves as it does at runtime, and a field no
827+
* body declares or extends is still a miss. `{ tied: 'drop' }` is the graph a
828+
* type arm judges on ({@link objectsWithoutTiedFields}).
829+
*/
830+
export function indexServedObjectGraph(stack: unknown, options: { tied?: 'keep' | 'drop' } = {}): ObjectGraph {
831+
const served = objectsAsServed(stack);
832+
return indexObjectGraph({ objects: options.tied === 'drop' ? objectsWithoutTiedFields(served) : served.objects });
833+
}

‎packages/lint/src/validate-dataset-measure-aggregates.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ import { DATASET_DURATION_FIELD_TYPE } from '@objectstack/spec/ui';
222222

223223
import { collectionEntries } from './collection-entries.js';
224224
import {
225-
indexObjectGraph,
225+
indexServedObjectGraph,
226226
isUnjudgeable,
227227
recordsOf,
228228
resolveFieldPath,
@@ -651,7 +651,9 @@ export function validateDatasetMeasureAggregates(stack: unknown): DatasetMeasure
651651
const cubes = collectionEntries(stack.analyticsCubes, 'analyticsCubes');
652652
if (datasets.length === 0 && cubes.length === 0) return findings;
653653

654-
const graph: ObjectGraph = indexObjectGraph(stack);
654+
// [#30383] Served, a field two packages tie on left out: which definition
655+
// wins is registration order, so its type is not judged here.
656+
const graph: ObjectGraph = indexServedObjectGraph(stack, { tied: 'drop' });
655657

656658
datasets.forEach((ds, di) => {
657659
// ── Skip 1: no base object, or one the graph cannot answer for ──

‎packages/lint/src/validate-dataset-references.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@
119119
import { walkFilterFieldKeys } from './filter-walk.js';
120120
import {
121121
describeFieldPathVerdict,
122-
indexObjectGraph,
122+
indexServedObjectGraph,
123123
isUnjudgeable,
124124
joinablePrefixes,
125125
recordsOf,
@@ -182,7 +182,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
182182
const datasets = recordsOf(stack.datasets);
183183
if (datasets.length === 0) return findings;
184184

185-
const graph: ObjectGraph = indexObjectGraph(stack);
185+
const graph: ObjectGraph = indexServedObjectGraph(stack);
186186

187187
datasets.forEach((ds, di) => {
188188
const dsName = strName(ds.name) ?? `#${di}`;

‎packages/lint/src/validate-field-consumers.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@
165165
* `controlled_by_parent` sharing, roll-ups and inline grids as its
166166
* readers; the relationship is consumed by being declared).
167167
* - **Object extensions** (`objectExtensions`) are not judged: the fields
168-
* they add belong to objects this stack does not own.
168+
* they add belong to objects this stack does not own. They are still
169+
* READ (#30383): a dataset or cube path is resolved over the objects as
170+
* served, so a hop through an extension-added relationship credits the
171+
* declared field it reaches.
169172
*
170173
* Severity is `warning` and stays so: a refusal would narrow the authorable
171174
* surface (today-valid metadata would start being refused), which is the
@@ -183,7 +186,7 @@ import type { DisplayNameObjectMeta } from '@objectstack/spec/data';
183186
import { referenceTargetOf } from '@objectstack/spec/data';
184187
import { collectionEntries } from './collection-entries.js';
185188
import {
186-
indexObjectGraph,
189+
indexServedObjectGraph,
187190
isUnjudgeable,
188191
joinablePrefixes,
189192
recordsOf,
@@ -1194,7 +1197,7 @@ function creditColumnPath(
11941197
* walk.
11951198
*/
11961199
function creditAnalyticsColumns(ledger: ConsumerLedger, stack: AnyRec): void {
1197-
const graph = indexObjectGraph(stack);
1200+
const graph = indexServedObjectGraph(stack);
11981201
for (const { rec: ds, path: dsPath } of collectionEntries(stack.datasets, 'datasets')) {
11991202
const object = strName(ds.object);
12001203
if (!object || !graph.get(object)) continue;

‎packages/lint/src/validate-list-view-field-refs.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,8 @@ import {
428428
describeFieldPathVerdict,
429429
indexObjectGraph,
430430
isUnjudgeable,
431+
objectsAsServed,
432+
objectsWithoutTiedFields,
431433
recordsOf,
432434
resolveFieldPath,
433435
suggestName,
@@ -959,7 +961,12 @@ export function validateListViewFieldRefs(
959961
const findings: ListViewFieldRefFinding[] = [];
960962
if (!isRec(stack)) return findings;
961963

962-
const graph: ObjectGraph = indexObjectGraph(stack);
964+
// [#30383] The objects as the registry serves them, every `objectExtensions`
965+
// field folded in: the graph and the option map below both read this, so a
966+
// column, filter key or `groupByField` naming a field a sibling package adds
967+
// resolves as it does at runtime.
968+
const servedStack = objectsAsServed(stack);
969+
const graph: ObjectGraph = indexObjectGraph({ objects: servedStack.objects });
963970
const scope = options?.packageScope;
964971
// [#24668] The whole collection, stated — and only without a closure, which
965972
// is the narrower set and wins.
@@ -1058,11 +1065,13 @@ export function validateListViewFieldRefs(
10581065

10591066
/**
10601067
* [#28018] The inline option values each object's fields declare, read from
1061-
* the same `stack.objects` the graph indexes. A field with no non-empty
1062-
* inline `options` array is absent: its lanes are not judged.
1068+
* the same served objects the graph indexes. A field with no non-empty
1069+
* inline `options` array is absent: its lanes are not judged. [#30383] So is
1070+
* a field two packages tie on: which definition's options win is
1071+
* registration order.
10631072
*/
10641073
const optionValues = new Map<string, Map<string, ReadonlySet<string>>>();
1065-
for (const obj of recordsOf(stack.objects)) {
1074+
for (const obj of objectsWithoutTiedFields(servedStack)) {
10661075
const objName = strName(obj.name);
10671076
if (!objName) continue;
10681077
const byField = new Map<string, ReadonlySet<string>>();

‎packages/lint/src/validate-mapping-target-fields.test.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,22 +181,31 @@ describe('validateMappingTargetFields', () => {
181181
});
182182

183183
it('folds fields an objectExtensions entry merges into the object — from the stack and from a sibling package', () => {
184-
const stack = {
184+
const mappings = [mapping([
185+
{ source: 'Tier', target: 'sla_tier' },
186+
{ source: 'Region', target: 'region' },
187+
])];
188+
// [#30383] Read through the shared fold, as the registry registers them:
189+
// the stack's own entries when it carries no `packages[]`, and every
190+
// body's when it does (an artifact's top level repeats its bodies).
191+
expect(validateMappingTargetFields({
185192
objects: [contact],
186-
objectExtensions: [{ extend: 'crm_contact', fields: { sla_tier: { type: 'text' } } }],
187-
packages: [{ manifest: { objectExtensions: [{ extend: 'crm_contact', fields: { region: { type: 'text' } } }] } }],
188-
mappings: [mapping([
189-
{ source: 'Tier', target: 'sla_tier' },
190-
{ source: 'Region', target: 'region' },
191-
])],
192-
};
193-
expect(validateMappingTargetFields(stack)).toEqual([]);
193+
objectExtensions: [{ extend: 'crm_contact', fields: { sla_tier: { type: 'text' }, region: { type: 'text' } } }],
194+
mappings,
195+
})).toEqual([]);
196+
expect(validateMappingTargetFields({
197+
objects: [contact],
198+
packages: [
199+
{ manifest: { objects: [contact], objectExtensions: [{ extend: 'crm_contact', fields: { sla_tier: { type: 'text' } } }] } },
200+
{ manifest: { objectExtensions: [{ extend: 'crm_contact', fields: { region: { type: 'text' } } }] } },
201+
],
202+
mappings,
203+
})).toEqual([]);
194204
});
195205

196206
it('CONTROL — `packages: undefined` (absent) stays silent — the only value this reader treats as absent', () => {
197-
// `full_name`, not `sla_tier`: `sla_tier` resolves via the STACK's own
198-
// top-level `objectExtensions` (the test above, :173) — `region` is the
199-
// one that needs a package (:174). Either way, with `packages` genuinely
207+
// `full_name`, not `sla_tier`: `sla_tier` resolves only through an
208+
// `objectExtensions` entry (the test above). With `packages` genuinely
200209
// absent this control needs a field `contact` declares on its own, so a
201210
// real finding can't masquerade as the reader silently accepting the
202211
// shape.

‎packages/lint/src/validate-mapping-target-fields.ts‎

Lines changed: 11 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@
5151
*
5252
* Fields an `objectExtensions[]` entry merges into the object (from this stack
5353
* or from a sibling package of the same artifact) ARE addressable at runtime,
54-
* because the registry merges every extension layer onto its target, so they
55-
* are folded into the object's field map before it is judged. Fields injected
54+
* because the registry merges every extension layer onto its target, so the
55+
* object is judged as served: through the package's one fold
56+
* (`objectsAsServed`, #30383), which keeps one entry per package per target
57+
* as the registry does. Fields injected
5658
* imperatively by plugin code are not metadata and no static rule can see
5759
* them; the import door, which reads the registered object, still judges them.
5860
*/
@@ -64,7 +66,7 @@ import {
6466
type ImportMappingTargetHead,
6567
} from '@objectstack/spec/data';
6668

67-
import { packagesOf, recordsOf, suggestName } from './object-graph.js';
69+
import { objectsAsServed, packagesOf, recordsOf, suggestName } from './object-graph.js';
6870

6971
/**
7072
* A `fieldMapping[].target` the mapping cannot write on its object: it names no
@@ -94,30 +96,6 @@ function strName(v: unknown): string | undefined {
9496
return typeof v === 'string' && v.length > 0 ? v : undefined;
9597
}
9698

97-
/**
98-
* Target object name → the field records every `objectExtensions[]` entry
99-
* aimed at it contributes, read from the stack in hand and from each
100-
* `packages[].manifest` body (the per-package leg of `os build` sees a
101-
* sibling's extension only there).
102-
*/
103-
function extensionFieldsByTarget(stack: AnyRec): Map<string, AnyRec[]> {
104-
const byTarget = new Map<string, AnyRec[]>();
105-
const add = (extensions: unknown) => {
106-
for (const extension of recordsOf(extensions)) {
107-
const target = strName(extension.extend);
108-
if (!target) continue;
109-
const fields = recordsOf(extension.fields);
110-
if (fields.length === 0) continue;
111-
byTarget.set(target, [...(byTarget.get(target) ?? []), ...fields]);
112-
}
113-
};
114-
add(stack.objectExtensions);
115-
for (const entry of packagesOf(stack)) {
116-
if (isRec(entry.manifest)) add(entry.manifest.objectExtensions);
117-
}
118-
return byTarget;
119-
}
120-
12199
/**
122100
* Report every import-mapping target its mapping cannot write on its object:
123101
* 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
130108
const mappings = recordsOf(stack.mappings);
131109
if (mappings.length === 0) return findings;
132110

111+
// A present non-array `packages` is still refused (ruling A on #15293): the
112+
// shared fold reads such a value as absent, so the refusal is asked here.
113+
packagesOf(stack);
133114
const objects = new Map<string, AnyRec>();
134-
for (const obj of recordsOf(stack.objects)) {
115+
for (const obj of objectsAsServed(stack).objects) {
135116
const n = strName(obj.name);
136117
if (n) objects.set(n, obj);
137118
}
138-
const extensionFields = extensionFieldsByTarget(stack);
139119

140120
for (let mi = 0; mi < mappings.length; mi++) {
141121
const mapping = mappings[mi];
142122
const objectName = strName(mapping.targetObject);
143123
if (!objectName) continue; // a missing targetObject is the schema's subject
144-
const declared = objects.get(objectName);
145-
if (!declared) continue; // skip 1
146-
const extra = extensionFields.get(objectName);
147-
const objectDef: AnyRec = extra
148-
? { ...declared, fields: [...recordsOf(declared.fields), ...extra] }
149-
: declared;
124+
const objectDef = objects.get(objectName);
125+
if (!objectDef) continue; // skip 1
150126

151127
const misses = unknownImportMappingTargets(mapping.fieldMapping, objectDef);
152128
if (misses.length === 0) continue; // clean, or skip 2

‎packages/lint/src/validate-object-field-refs.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@
211211

212212
import {
213213
describeFieldPathVerdict,
214-
indexObjectGraph,
214+
indexServedObjectGraph,
215215
isUnjudgeable,
216216
recordsOf,
217217
resolveFieldPath,
@@ -466,7 +466,7 @@ export function validateObjectFieldRefs(stack: AnyRec): ObjectFieldRefFinding[]
466466
const findings: ObjectFieldRefFinding[] = [];
467467
if (!isRec(stack)) return findings;
468468

469-
const graph: ObjectGraph = indexObjectGraph(stack);
469+
const graph: ObjectGraph = indexServedObjectGraph(stack);
470470
if (graph.size === 0) return findings;
471471

472472
/**

0 commit comments

Comments
 (0)