From 1d964ebacaee225d928586596945a0e6f5423935 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 02:41:27 +0000 Subject: [PATCH] fix(spec): mirror the objectName -> object check onto embedded actions (#7456) The third arm #7397 deliberately left open. The registered action walk in validateCrossReferences applies three checks -- flow target, modal target, and objectName -> declared object -- but #7397's PR mirrored only the first two onto config.objects[].actions[]. This closes the third arm (Option A, existence check, per the 2026-08-11/12 maintainer-confirmed ruling): a dangling objectName on an object-embedded action is now refused the same way it already is at the registered position. objectName still gives no new runtime meaning at the embedded position -- mergeActionsIntoObjects continues to read only config.actions -- this only makes a dangling value refused at authoring time. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ECjShwqVRAhLy15mgatGjV --- .../embedded-action-objectname-crossref.md | 52 +++++++++++++++++++ .../src/stack-inline-action-crossref.test.ts | 51 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 24 +++++++-- 3 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 .changeset/embedded-action-objectname-crossref.md diff --git a/.changeset/embedded-action-objectname-crossref.md b/.changeset/embedded-action-objectname-crossref.md new file mode 100644 index 0000000000..5b1af7162f --- /dev/null +++ b/.changeset/embedded-action-objectname-crossref.md @@ -0,0 +1,52 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): `defineStack`'s action cross-reference walk now checks `objectName` on OBJECT-EMBEDDED actions too (#7456) + +The third arm #7397 deliberately left open. The registered action walk in +`validateCrossReferences` applies **three** checks — flow target, modal target, and +`objectName` → declared object — but #7397's PR mirrored only the first two onto +`config.objects[].actions[]`. The third split the same way the target arms did before +#7397: + +``` +embedded { name: 'probe_on', type: 'script', target: 'doThing', objectName: 'probe_missing' } -> ACCEPTED +REGISTERED { name: 'probe_on', type: 'script', target: 'doThing', objectName: 'probe_missing' } -> REJECTED + "Action 'probe_on' references object 'probe_missing' which is not defined in objects." +``` + +Same action object, two authoring positions, opposite verdicts — the b/f, c/g, d/i pattern +from #7397's probe table, one key over. + +**Now**: `config.objects[].actions[]` is walked and every action's `objectName` (when set) is +subjected to the **same** existence check as a registered action's. Message keeps the +registered wording from `references` onward and changes only the subject, same convention +as the flow/modal arms: + +``` +Action 'probe_on' on object 'probe_task' references object 'probe_missing' which is not +defined in objects. +``` + +**Ruling.** #7456 filed this as an observation-class finding because mirroring the check is +an acceptance-surface change with two live readings: **A** — existence check (verbatim +mirror, what this PR does) vs **B** — consistency check (the value must equal the owning +object's own name). The maintainer's four-lens review on 2026-08-11 deferred the A/B/C +question to the spec/contract cadence; the 2026-08-11T14:32Z findings-triage round then +graded it **promote** under the standing 元判据 — a silently-dropped declaration on one +sibling authoring position joins the sibling branch's existing refusal set — and ruled +**Option A**. This PR implements exactly that. It does **not** foreclose B or C: an embedded +action naming a *different* declared object than its owner is still accepted (only a +*dangling* value newly refuses), and whether the key should instead be retired at this +position remains open. + +**Acceptance-face narrowing.** A stack carrying a dangling embedded `objectName` now fails +to build where it previously built clean. Census of the shipped corpus (`examples/*`, +`content/docs/**`) found **zero** `*.object.ts` files — or any other file — declaring +`objectName` at the embedded position at all, dangling or otherwise; the key is used only at +the registered/top-level position in shipped metadata today. + +`objectName` still gives no new RUNTIME meaning at the embedded position: +`mergeActionsIntoObjects` continues to build its map only from `config.actions`, never from +`obj.actions[].objectName` — this PR only makes a dangling value refused at authoring time. diff --git a/packages/spec/src/stack-inline-action-crossref.test.ts b/packages/spec/src/stack-inline-action-crossref.test.ts index 1f386c26e3..42a1ab2798 100644 --- a/packages/spec/src/stack-inline-action-crossref.test.ts +++ b/packages/spec/src/stack-inline-action-crossref.test.ts @@ -45,6 +45,21 @@ * with opposite verdicts; a/h and e/j confirm the legitimate shapes survive in * both. Row d's verdict is fixed by the same #6739 ruling, not re-decided here. * + * A THIRD arm was left deliberately unmirrored by #7397's own PR: `objectName` + * → declared object, "one key over" from the target arms above (#7456): + * + * ``` + * k embedded objectName -> missing object : ACCEPTED (l registered: REJECTED) ← split + * ``` + * + * #7456's maintainer-ruled disposition is Option A (existence check, verbatim + * mirror — the same 元判据 that fixed rows b/f, c/g, d/i: a silently-dropped + * declaration joins the sibling branch's existing refusal set). Option A does + * NOT check that an embedded `objectName` agrees with its owning object — an + * embedded action naming a DIFFERENT declared object is accepted, same as + * before; only a DANGLING `objectName` newly refuses. Options B (consistency) + * and C (retirement) remain open and are not exercised here. + * * Message shape is contract here (one condition ⇒ one wording), so these pin * full message text rather than `toThrow()` alone: a bare throw assertion * cannot tell "refused for the right reason" from "refused because the fixture @@ -350,6 +365,42 @@ describe('defineStack — object-embedded action cross-references: flow targets }); }); +describe('defineStack — object-embedded action cross-references: objectName → object (#7456)', () => { + const dangling = { name: 'probe_on', label: 'On', type: 'script' as const, target: 'doThing', objectName: 'probe_missing' }; + + it('rejects a dangling embedded objectName (probe row k) with the registered rule\'s wording, subject adjusted to the owning object', () => { + expect(refusals(embeddedStack(dangling))).toEqual([ + "Action 'probe_on' on object 'probe_task' " + + "references object 'probe_missing' which is not defined in objects.", + ]); + }); + + it('closes the b/f, c/g, d/i pattern one key over (rows k/l): the same action gets the same verdict embedded or registered', () => { + expect(refusals(embeddedStack(dangling)).length).toBe(1); + expect(refusals(registeredStack(dangling)).length).toBe(1); + }); + + it('accepts an embedded objectName naming its own owning object', () => { + expect(refusals(embeddedStack({ name: 'probe_on', label: 'On', type: 'script' as const, target: 'doThing', objectName: 'probe_task' }))).toEqual([]); + }); + + it('accepts an embedded objectName naming a DIFFERENT declared object — Option A is an existence check only, not a consistency check (B stays open)', () => { + const config = { + manifest: baseManifest, + objects: [ + { ...objects[0], actions: [{ name: 'probe_on', label: 'On', type: 'script' as const, target: 'doThing', objectName: 'probe_note' }] }, + { name: 'probe_note', label: 'Probe Note', fields: { body: { type: 'text' as const } } }, + ], + pages, + }; + expect(refusals(config)).toEqual([]); + }); + + it('leaves an embedded action with no objectName alone — unchanged from before #7456', () => { + expect(refusals(embeddedStack({ name: 'probe_on', label: 'On', type: 'script' as const, target: 'doThing' }))).toEqual([]); + }); +}); + describe('defineStack — object-embedded action cross-references: the b/f, c/g, d/i splits (#7397)', () => { it.each([ ['modal → nothing (rows b/f)', modalAction('probe_nowhere')], diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ae8ee8761b..090dd89acc 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1175,11 +1175,19 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { // Scope of the modal branch is the same #6739 ruling the other two arms // read: a `type: 'modal'` target names a PAGE, only. // - // `objectName` is deliberately NOT checked here. The key exists on this - // shape (unlike the inline one), but what it should MEAN on an action - // already embedded on an object — a mere existence check, or a consistency - // check against the owning object's name — is an open contract question, - // filed separately rather than settled as a side effect of this walk. + // Third arm: `objectName` → declared object, mirrored onto the embedded + // position (#7456, Option A — existence check, ruled 2026-08-11/12). This + // is the identical check the registered walk applies above; only the + // SUBJECT differs, same as the flow/modal arms just above. It does NOT + // give `objectName` new meaning at this position — `mergeActionsIntoObjects` + // still builds its map only from `config.actions`, so the key stays inert + // for merge purposes. It only makes a dangling value refused at authoring + // time, same as it already is in the registered position. + // + // Deliberately NOT checked: whether `objectName` here must equal the + // owning object's own name (Option B), or whether the key should be + // retired at this position instead (Option C) — both stay open per the + // issue and are not foreclosed by this existence check. if (config.objects) { for (const obj of config.objects) { for (const action of obj.actions ?? []) { @@ -1194,6 +1202,12 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { `Action '${action.name}' on object '${obj.name}' references page '${action.target}' (via modal target) which is not defined in pages.`, ); } + + if (action.objectName && !objectNames.has(action.objectName)) { + errors.push( + `Action '${action.name}' on object '${obj.name}' references object '${action.objectName}' which is not defined in objects.`, + ); + } } } }