diff --git a/.changeset/lookup-reference-target-gate.md b/.changeset/lookup-reference-target-gate.md new file mode 100644 index 0000000000..53ef53a1bd --- /dev/null +++ b/.changeset/lookup-reference-target-gate.md @@ -0,0 +1,25 @@ +--- +'@objectstack/lint': minor +'@objectstack/cli': patch +--- + +`object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` + +`FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. + +The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: + +1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; +2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; +3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; +4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. + +Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). + +## Migration + +**A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. + +**A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. + +**A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 035379744f..2c7bf4a4f4 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -95,9 +95,40 @@ function artifactPackages(parsed: Record): Array<{ * would REFUSE, by name, every collection key this superset deliberately puts * under `manifest` — and re-opening it to stop the refusal would re-open the * real manifest surface with it. + * + * ## `packages` — the second key, and the only one (#16611) + * + * The body carries the collections this package OWNS. Judged with nothing else, + * a rule that resolves an object NAME concludes that a name a SIBLING package in + * the same artifact ships exists nowhere: `examples/app-multi-package`'s + * `crm_order.account` → `crm_account` — a lookup that fixture's README + * documents as the point of the fixture — is owned by its `core` package and + * read from its `orders` one. ADR-0130 makes the release artifact the + * co-ownership boundary, so a per-package pass that cannot see a sibling + * package's objects is THIS RUN's defect and not the author's; the ruled fix + * (director seat, decision batch #86) hands the per-package stack the + * artifact's own `packages[]` as RESOLUTION CONTEXT. + * + * Two properties make that safe, and they are the whole design: + * + * 1. ⛔ It changes what a rule can RESOLVE, never what it JUDGES. The + * collections read off the top level are still this package's alone, so + * every per-package finding this leg exists to produce is still produced. + * ⛔ This is NOT "skip the site per package" — that would silence the gate + * on the one command that ships. + * 2. A name no entry of `packages[]` provides resolves in neither run, so a + * genuinely dangling reference still errors here exactly as it does in the + * union run above. + * + * The array is passed through verbatim rather than reduced to a name list: the + * consuming rule owns which of an entry's contents are resolution context, and a + * set computed here would be a second copy of that decision, free to drift. */ -function packageBodyAsStack(body: Record): Record { - return { ...body, manifest: body }; +function packageBodyAsStack( + body: Record, + artifactPackageEntries: unknown, +): Record { + return { ...body, manifest: body, packages: artifactPackageEntries }; } /** Identity of one finding, for the per-package de-duplication below. */ @@ -437,6 +468,13 @@ export default class Compile extends Command { // every finding twice and the author cannot tell a real per-package // finding from an echo. What survives the filter is exactly the set // the union could not see. + // + // [#16611] Each package's stack is handed the artifact's `packages[]` + // as RESOLUTION CONTEXT — see `packageBodyAsStack`. The list read here + // is the one `artifactPackages` above walked, off the same parsed + // stack, so the context a package resolves against is exactly the set + // of packages this artifact will register (ADR-0130 D4/D5). + const artifactPackageEntries = (result.data as Record).packages; const packageEntries = artifactPackages(result.data as Record); if (packageEntries.length > 0) { if (!flags.json) { @@ -445,7 +483,7 @@ export default class Compile extends Command { const alreadyReported = new Set(findings.map(findingKey)); const perPackageErrors: Array<{ package: string } & typeof ruleErrors[number]> = []; for (const pkg of packageEntries) { - const asStack = packageBodyAsStack(pkg.body); + const asStack = packageBodyAsStack(pkg.body, artifactPackageEntries); const pkgFindings = runAuthoringRules('build', { normalized: asStack, parsed: asStack, diff --git a/packages/lint/src/validate-object-references.test.ts b/packages/lint/src/validate-object-references.test.ts index c453a68189..f199cbc146 100644 --- a/packages/lint/src/validate-object-references.test.ts +++ b/packages/lint/src/validate-object-references.test.ts @@ -80,6 +80,231 @@ describe('validateObjectReferences — action params', () => { }); }); +describe('validateObjectReferences — field relationship targets (#16611)', () => { + // The card's control probe, verbatim: on 17.3.0 `os validate`, `os lint` and + // `os build` all exited 0 on it, with no diagnostic of any severity. + it('errors on a lookup whose reference names an object declared nowhere', () => { + const stack = baseStack(); + (stack.objects[0].fields as Record).zzz_probe = { + type: 'lookup', + label: 'Control probe', + reference: 'zzz_object_that_does_not_exist', + }; + const findings = validateObjectReferences(stack); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('error'); + expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN); + expect(findings[0].where).toBe('object "crm_lead" · field "zzz_probe"'); + expect(findings[0].path).toBe('objects[0].fields.zzz_probe.reference'); + expect(findings[0].message).toContain('lookup target "zzz_object_that_does_not_exist"'); + // The hint says what the miss costs at runtime, not just that it is a miss. + expect(findings[0].hint).toContain('record picker'); + }); + + it('errors on a master_detail whose reference names an object declared nowhere', () => { + const stack = baseStack(); + (stack.objects[0].fields as Record).parent = { + type: 'master_detail', + required: true, + reference: 'crm_led', + }; + const findings = validateObjectReferences(stack); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('objects[0].fields.parent.reference'); + expect(findings[0].message).toContain('master_detail target "crm_led"'); + // One edit away from an own object: the suggester names it. + expect(findings[0].message).toContain('Did you mean "crm_lead"?'); + }); + + it('accepts a lookup into an own object and the `Field.user()` shape (rungs ① and ③)', () => { + const stack = baseStack(); + Object.assign(stack.objects[0].fields as Record, { + account: { type: 'lookup', reference: 'crm_account' }, + owner: { type: 'user', reference: 'sys_user' }, + watchers: { type: 'lookup', reference: 'sys_user', multiple: true }, + }); + expect(validateObjectReferences(stack)).toEqual([]); + }); + + it('warns (not errors) on a platform-shaped target no package registers (rung ④)', () => { + const stack = baseStack(); + (stack.objects[0].fields as Record).approval = { + type: 'lookup', + reference: 'sys_approval_process', + }; + const findings = validateObjectReferences(stack); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNREGISTERED_PLATFORM); + expect(findings[0].path).toBe('objects[0].fields.approval.reference'); + expect(findings[0].hint).toContain('sys_approval_request'); + }); + + it('leaves the `reference` key alone on `tree` and on non-relationship types', () => { + // `tree`: the schema already refuses any target but the own name + // (`refuseForeignTreeReference`), so a survivor always resolves. + // `text`: the key is inert there; a finding would be about the wrong thing. + const stack = baseStack(); + Object.assign(stack.objects[0].fields as Record, { + parent: { type: 'tree', reference: 'crm_lead' }, + note: { type: 'text', reference: 'zzz_object_that_does_not_exist' }, + }); + expect(validateObjectReferences(stack)).toEqual([]); + }); + + it('does not walk objectExtensions[].fields — cross-package by construction', () => { + // An extension adds fields to an object ANOTHER package owns; its targets + // are the cross-package case this ladder has no rung for, and the declared + // escape is its own card. Pinned so the boundary is a decision, not a gap. + const findings = validateObjectReferences({ + ...baseStack(), + objectExtensions: [ + { + object: 'crm_contract', + fields: { clause: { type: 'lookup', reference: 'zzz_object_that_does_not_exist' } }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('walks array-shaped and map-shaped field collections alike', () => { + const findings = validateObjectReferences({ + objects: [ + { name: 'crm_lead', fields: [{ name: 'owner', type: 'lookup', reference: 'user' }] }, + { name: 'crm_account', fields: { rep: { type: 'lookup', reference: 'user' } } }, + ], + }); + expect(findings.map((f) => f.path)).toEqual([ + 'objects[0].fields.owner.reference', + 'objects[1].fields.rep.reference', + ]); + for (const f of findings) { + expect(f.severity).toBe('error'); + expect(f.hint).toContain('sys_user'); + } + }); + + it('reports the field site before the sites that hang off the same object', () => { + const stack = baseStack(); + (stack.objects[0].fields as Record).zzz_probe = { + type: 'lookup', + reference: 'zzz_object_that_does_not_exist', + }; + (stack.objects[0] as Record).actions = [ + { name: 'convert', params: [{ name: 'target', type: 'lookup', reference: 'accounts' }] }, + ]; + expect(validateObjectReferences(stack).map((f) => f.path)).toEqual([ + 'objects[0].fields.zzz_probe.reference', + 'objects[0].actions[0].params[0].reference', + ]); + }); +}); + +describe('validateObjectReferences — artifact packages[] as resolution context (#16611)', () => { + /** + * The shape `compile.ts`'s per-package leg hands the rules (ADR-0130 D4): ONE + * package's collections at the top level, and the whole artifact's + * `packages[]` beside them as context. Modelled on + * `examples/app-multi-package`, whose `orders` package reads `crm_account` + * out of its `core` sibling. + */ + const ORDERS_BODY = { + id: 'com.example.multi.orders', + objects: [ + { + name: 'crm_order', + fields: { + number: { type: 'text' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + }, + ], + }; + const CORE_BODY = { + id: 'com.example.multi.core', + objects: [{ name: 'crm_account', fields: { name: { type: 'text' } } }], + }; + const perPackageStack = (body: Record, packages: unknown) => ({ + ...body, + manifest: body, + packages, + }); + + it('CONTROL — the same package judged ALONE still errors, so the context is what does the work', () => { + // Without this leg "green" is indistinguishable from the rung having been + // switched off: this is the exact finding that reds `Build Core` on + // `examples/app-multi-package` when the ladder lands by itself. + const findings = validateObjectReferences(perPackageStack(ORDERS_BODY, undefined)); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('error'); + expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN); + expect(findings[0].path).toBe('objects[0].fields.account.reference'); + expect(findings[0].message).toContain('lookup target "crm_account"'); + }); + + it('resolves a lookup into an object a SIBLING package of the same artifact provides', () => { + const artifact = [{ manifest: ORDERS_BODY }, { manifest: CORE_BODY }]; + expect(validateObjectReferences(perPackageStack(ORDERS_BODY, artifact))).toEqual([]); + // …and the sibling, judged from its own side, is unaffected. + expect(validateObjectReferences(perPackageStack(CORE_BODY, artifact))).toEqual([]); + }); + + it('NON-DEGENERACY — a name NO package in the artifact provides still errors', () => { + // The whole distinction between the ruled fix and "skip the field site per + // package": the site is still judged, against a wider set. `crm_contract` + // is the card's cross-REPO spelling — no entry here ships it. + const dangling = { + ...ORDERS_BODY, + objects: [ + { name: 'crm_order', fields: { contract: { type: 'lookup', reference: 'crm_contract' } } }, + ], + }; + const findings = validateObjectReferences( + perPackageStack(dangling, [{ manifest: dangling }, { manifest: CORE_BODY }]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('error'); + expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN); + expect(findings[0].path).toBe('objects[0].fields.contract.reference'); + // The remedy lists what the ARTIFACT provides, not only this package's own + // objects — that is the list the author actually has to choose from. + expect(findings[0].hint).toContain('Defined objects: crm_account, crm_order.'); + }); + + it('carries the context to every site on the rule, not only the field one', () => { + const withNav = { + ...ORDERS_BODY, + navigation: [{ name: 'accounts', objectName: 'crm_account', requiresObject: 'crm_account' }], + actions: [{ name: 'link', params: [{ name: 'a', type: 'lookup', reference: 'crm_account' }] }], + }; + expect( + validateObjectReferences( + perPackageStack(withNav, [{ manifest: withNav }, { manifest: CORE_BODY }]), + ), + ).toEqual([]); + }); + + it('reads only names a package REALLY declares — an entry with no body contributes none', () => { + // A future `{ ref, integrity }` external segment carries no manifest + // content (ADR-0130 D4). Inventing a name for such an entry would silence + // the ladder, which is the one mistake this context must not make. + const findings = validateObjectReferences( + perPackageStack(ORDERS_BODY, [{ ref: 'sha256-x' }, { manifest: { id: 'x' } }, 'not-an-entry']), + ); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].fields.account.reference'); + }); + + it('ignores a `packages` value that is not a list of entries', () => { + for (const packages of [null, 42, 'core']) { + const findings = validateObjectReferences(perPackageStack(ORDERS_BODY, packages)); + expect(findings.map((f) => f.path)).toEqual(['objects[0].fields.account.reference']); + } + }); +}); + describe('validateObjectReferences — dashboard global filters', () => { // The other HotCRM `object: 'user'` instance. it('errors on optionsFrom.object naming a nonexistent object', () => { diff --git a/packages/lint/src/validate-object-references.ts b/packages/lint/src/validate-object-references.ts index f8cb564cac..9a0969e420 100644 --- a/packages/lint/src/validate-object-references.ts +++ b/packages/lint/src/validate-object-references.ts @@ -38,6 +38,14 @@ * - the nav `objectName` of an item that carries `requiresObject` — exempted * from the `defineStack` throw for good reason (it may come from another * package), but still worth an advisory when NO known package provides it. + * - a field's `reference` (#16611) — the target of `Field.lookup()` / + * `Field.masterDetail()` / `Field.user()`. `FieldSchema.reference` is + * `z.string()`; the schema holds it present and non-empty on the two + * relationship types and nothing asked whether it resolved, so the ONE + * reference every record form depends on shipped whatever the author + * typed. Dead → the record picker asks the REST layer for an object that + * is not registered (404 `OBJECT_NOT_FOUND`), `$expand` on the field + * fails, and the form renders a control that can never resolve a value. * * ── Severity ladder (the point of the rule) ────────────────────────────── * @@ -47,7 +55,10 @@ * platform-prefixed reference shipped. This rule resolves against the curated * `PLATFORM_PROVIDED_OBJECT_NAMES` registry instead: * - * 1. resolves in the stack's own objects → OK + * 1. resolves in the stack's own objects, or in the objects an entry of this + * artifact's `packages[]` provides (ADR-0130 D4 — the release artifact IS + * the co-ownership boundary, so a name a sibling package in the SAME + * artifact ships is resolved, never guessed at) → OK * 2. unresolved, NOT platform-prefixed → ERROR * (`user`, `total_revenue` — no cross-package story exists for an * unprefixed name, since a stack's objects are namespace-prefixed; @@ -74,6 +85,19 @@ import { recordsOf, suggestName } from './object-graph.js'; /** Materialized once for the repeated edit-distance scans in `suggestName`. */ const PLATFORM_NAMES: readonly string[] = [...PLATFORM_PROVIDED_OBJECT_NAMES]; +/** + * The field types whose `reference` names ANOTHER object this rule resolves + * (#16611). `RELATIONSHIP_FIELD_TYPES` (`object-graph.ts`) minus `tree`, on + * purpose: a `tree` reference is optional and, when written, must name the + * declaring object itself — `object.zod.ts` (`refuseForeignTreeReference`) + * refuses every other target at parse time, so one that reaches this rule + * always lands on rung ①, and judging it here would only echo the schema. + * `user` is a member because `Field.user()` writes `reference: 'sys_user'`, + * which is exactly a rung-③ resolution. A `reference` on any other type is + * inert and is left to the schema. + */ +const RELATIONSHIP_TARGET_FIELD_TYPES: ReadonlySet = new Set(['lookup', 'master_detail', 'user']); + export const OBJECT_REFERENCE_UNKNOWN = 'object-reference-unknown'; export const OBJECT_REFERENCE_UNREGISTERED_PLATFORM = 'object-reference-unregistered-platform'; @@ -117,6 +141,37 @@ function isInterpolated(target: string): boolean { return open !== -1 && target.indexOf('}', open + 2) !== -1; } +/** + * Every object name declared by an entry of `packages[]` — what THIS ARTIFACT + * provides, beyond the collections the stack in hand carries at its top level. + * + * Read from the ADR-0130 D4 entry shape (`{ manifest: }`, + * `ArtifactPackageSchema`), which is a wrapper by decision: the body lives under + * `manifest` so a future `{ ref, integrity }` external segment is an additive + * key. An entry with no readable body contributes nothing — a segment reference + * carries no manifest content, and inventing a name for it would be the one + * mistake this context must not make, since a name in here SILENCES the ladder. + * + * ⛔ Nothing else is read off the entry: not its id, not its dependencies. The + * question this answers is only "does the artifact provide this object name", + * which is exactly the co-ownership boundary ADR-0130 draws — ⛔ not "is the + * referencing package allowed to depend on the providing one", which is + * `resolvePluginOrder`'s question (ADR-0130 D5 / ADR-0116) and belongs where the + * dependency graph lives, not in a name-resolution rule. + */ +function artifactProvidedObjectNames(stack: AnyRec): string[] { + const names: string[] = []; + for (const entry of recordsOf(stack.packages)) { + const body = entry.manifest; + if (!body || typeof body !== 'object' || Array.isArray(body)) continue; + for (const obj of recordsOf((body as AnyRec).objects)) { + const n = strName(obj.name); + if (n) names.push(n); + } + } + return names; +} + /** * Validate every object-name reference on the surfaces listed in the module * header. Returns findings (empty = clean). @@ -132,6 +187,27 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { if (n) ownObjects.add(n); } + /** + * Rung ① widened to the ARTIFACT — this stack's own objects PLUS every object + * name the artifact's `packages[]` provide (#16611). + * + * `os build` runs this table twice: once over the union and once per PACKAGE + * (`compile.ts` step 3b-ii), because the artifact registers per package. The + * per-package leg used to hand each package's body over with nothing else, so + * a reference from one package into an object a SIBLING package in the same + * artifact ships resolved to nothing and landed on rung ② — an error. ADR-0130 + * makes the release artifact the co-ownership boundary, so that miss is the + * RUN's blind spot, not the author's mistake, and the fix is to give the run + * the context it was missing rather than to stop judging the site. + * + * ⛔ This is not a skip. Every name is still resolved; what changed is the set + * it resolves against, so a reference no package in the artifact provides is + * still rung ② and still errors — on the per-package leg exactly as on the + * union one. + */ + const resolvable = new Set(ownObjects); + for (const n of artifactProvidedObjectNames(stack)) resolvable.add(n); + /** * Resolve one reference through the ladder and record a finding if it fails. * `subject` describes the reference for the message ("record-picker target"). @@ -146,7 +222,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { const name = strName(target); if (!name) return; if (isInterpolated(name)) return; // resolved at render time - if (ownObjects.has(name)) return; // ① own object + if (resolvable.has(name)) return; // ① own object, or one this artifact's packages[] provide if (isPlatformProvidedObjectName(name)) return; // ③ known platform object if (hasPlatformObjectPrefix(name)) { @@ -181,14 +257,53 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { message: `${subject} "${name}" resolves to no object defined in this stack. ` + `The reference is inert at runtime — nothing reports the miss.` + - suggestName(name, ownObjects), + suggestName(name, resolvable), hint: `Point it at one of this stack's objects, or at a platform object by its full ` + `name (the platform user object is "sys_user", not "user"). ${fix}` + - (ownObjects.size > 0 ? ` Defined objects: ${[...ownObjects].sort().join(', ')}.` : ''), + (resolvable.size > 0 ? ` Defined objects: ${[...resolvable].sort().join(', ')}.` : ''), }); }; + // ── Object fields → relationship targets (#16611) ── + // The reference site every record form depends on, and the last one on this + // rule's list to be enrolled. Measured on 17.3.0: `os validate`, `os lint` + // and `os build` all exited 0 on `Field.lookup('zzz_object_that_does_not_exist')` + // — `defineStack`'s `validateCrossReferences` never read a field, and the + // `relationship/missing-reference` lint asks only whether the key is + // PRESENT. The same ladder as every other site here: an unprefixed miss is + // the typo class and gates; a platform-shaped miss no package registers + // advises. + // + // `objectExtensions[].fields` is deliberately NOT walked. An extension exists + // to add fields to an object ANOTHER package owns, and the package it owns it + // from is routinely one this artifact does NOT carry — a platform object, an + // official plugin's, another product's. `resolvable` covers the packages in + // THIS artifact and nothing beyond it, so judging an extension here would + // refuse the legitimate cross-ARTIFACT case by the rule that exists to catch + // the typo. That case's declared escape (resolution against declared manifest + // dependencies) is its own card; ⛔ not an authored marker on the field. + for (let oi = 0; oi < objects.length; oi++) { + const obj = objects[oi]; + if (!obj || typeof obj !== 'object') continue; + const objName = strName(obj.name) ?? `#${oi}`; + const fields = recordsOf(obj.fields); + for (let fi = 0; fi < fields.length; fi++) { + const field = fields[fi]; + const type = strName(field.type); + if (!type || !RELATIONSHIP_TARGET_FIELD_TYPES.has(type)) continue; + const fieldName = strName(field.name) ?? `#${fi}`; + check( + strName(field.reference), + `object "${objName}" · field "${fieldName}"`, + `objects[${oi}].fields.${fieldName}.reference`, + `${type} target`, + 'The record picker has no object to query, `$expand` has nothing to resolve, and the ' + + 'form renders a relationship control that can never resolve a value.', + ); + } + } + // ── Actions (global + object-embedded) → param object targets ── const checkActionParams = (action: AnyRec, actionPath: string, actionLabel: string) => { const params = recordsOf(action.params);