From d1ec90e7710ef04f5a8711835fa0a4aef00e35e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 05:44:27 +0000 Subject: [PATCH] refactor(runtime): spell the object-less action key as the constant (#14678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key is written once. #14422 converged the owner-key ladder and the ObjectQL plugin's copy of it; three bare `'global'` spellings elsewhere in `packages/runtime/src/action-execution.ts` were never in that card's path, because the runtime fence it built was a re-export plus a delegating alias. All three are equal in value to the constant today — which is the defect, not a mitigation: it is what made them invisible to every test in the repo, and what would have let them part from the constant in silence. - `seedFlowActionParams`: the live comparison deciding whether an object-derived `Id` param key is seeded. Substitution only — the predicate is unchanged, so `'*'` still falls through exactly as before. - `enforceActionParams`: the warn-once dedup key, also interpolated into the operator-facing `[action-params] : …` line. - `collectActionDeclarations`'s docblock, which also called the key "the wildcard" against `action-governance.ts`'s "no wildcard semantics". It now reads as the sibling docblock 48 lines below already did. `action-owner-key-single-source.test.ts` gains a half C that reads `action-execution.ts` and fails if any quote spelling is written out by hand again. The forbidden spelling is derived from the constant, so the guard is not itself another copy of the literal it forbids. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- ...erge-runtime-global-action-key-literals.md | 47 +++++++++++++++++ packages/runtime/src/action-execution.ts | 6 +-- .../action-owner-key-single-source.test.ts | 52 +++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 .changeset/converge-runtime-global-action-key-literals.md diff --git a/.changeset/converge-runtime-global-action-key-literals.md b/.changeset/converge-runtime-global-action-key-literals.md new file mode 100644 index 0000000000..beea7f01ec --- /dev/null +++ b/.changeset/converge-runtime-global-action-key-literals.md @@ -0,0 +1,47 @@ +--- +"@objectstack/runtime": patch +--- + +refactor(runtime): spell the object-less action key as `GLOBAL_ACTION_OBJECT_KEY` in `action-execution.ts` (#14678) + +`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key is +written once. #14422 converged the owner-key LADDER and the ObjectQL plugin's +copy of it; three bare `'global'` spellings elsewhere in +`packages/runtime/src/action-execution.ts` were never in that card's path, +because the runtime fence it built was a re-export plus a delegating alias. +This converges those three. The constant was already imported in the file. + +No behaviour moves — the constant is `'global'`, so every site is equal in +value before and after. That equality is the entire defect: it is what made the +three invisible to every test in the repo, and what would have let them part +from the constant in silence the day its value changes. + +- `seedFlowActionParams` — a live comparison (`objectName !== 'global'`) that + decides whether an object-derived `Id` param key is seeded. The one + site where a drifted literal would change what an action body receives. +- `enforceActionParams` — the warn-once dedup key, which is also interpolated + into the operator-facing `[action-params] : …` line. Converged rather + than left: the argument for a literal here is that a log key must never fail + to render, and that argument does not survive contact with the fact that + `GLOBAL_ACTION_OBJECT_KEY` is a module-scope `const string` already imported + into this file — it cannot fail to render either. What a drift there would + actually cost is an operator grepping logs by the key the engine now uses and + silently missing these lines. +- `collectActionDeclarations`'s docblock, which carried a second defect + independent of the literal: it called the key "the `'global'` wildcard", + contradicting `action-governance.ts` ("an exact-string `Map` lookup with no + wildcard semantics"). It is now the phrasing the sibling docblock 48 lines + below it already used — "the object-less `GLOBAL_ACTION_OBJECT_KEY`" — so the + correction is copied from the file's own converged prose rather than invented. + +`patch`, not `skip-changeset`: `packages/runtime` publishes `dist`, which is +built from this source, so the emitted bytes move even though the behaviour +does not. Nothing reaches the published entry — `action-execution.ts` is not +re-exported from `packages/runtime/src/index.ts` and no export, signature or +type changed here — which is what keeps it below `minor`. + +The docblock that promised the lockstep is joined by a weld that enforces it: +`action-owner-key-single-source.test.ts` gains a half C that reads +`action-execution.ts` and fails if any quote spelling of the key is written out +by hand again. The forbidden spelling is DERIVED from the constant rather than +hard-coded, so the guard is not itself a fourth copy of the literal it forbids. diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index b049a0e537..122fbe6bed 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -638,7 +638,7 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps, if (rowId != null) { const keys = new Set(['recordId']); - if (objectName && objectName !== 'global') { + if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) { keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`); } if (typeof action?.recordIdParam === 'string' && action.recordIdParam) { @@ -1013,7 +1013,7 @@ export function enforceActionParams(deps: ActionExecutionDeps, if (!laxActionParams()) { return `Invalid action params: ${summary}`; } - const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`; + const key = `${where.objectName ?? GLOBAL_ACTION_OBJECT_KEY}/${where.actionName ?? action?.name ?? 'action'}`; warnActionParamsOnce( key, `[action-params] ${key}: ${summary} — accepted because ` + @@ -1487,7 +1487,7 @@ export async function resolveActionByName(deps: ActionExecutionDeps, * engine executes since #2608 (`resyncAuthoredActions`) but that never * appear inside any object definition. Their owning object follows the * same convention as the engine registration key (`objectName` field, - * legacy `object` field, else the `'global'` wildcard). + * legacy `object` field, else the object-less `GLOBAL_ACTION_OBJECT_KEY`). * * On a key clash (`objectName:name`) the object-embedded declaration wins, * mirroring the execution layer's artifact-wins rule — `resyncAuthoredActions` diff --git a/packages/runtime/src/action-owner-key-single-source.test.ts b/packages/runtime/src/action-owner-key-single-source.test.ts index b4db49e92e..53aab30cc5 100644 --- a/packages/runtime/src/action-owner-key-single-source.test.ts +++ b/packages/runtime/src/action-owner-key-single-source.test.ts @@ -25,6 +25,15 @@ * copy: a byte-identical second spelling passes every assertion in half A. Half * B reads this package's own source and fails if the ladder grows a second * body here. + * + * Half C closes the same hole one level down (#14678). #14422 converged the + * LADDER, and the runtime kept three bare `'global'` spellings elsewhere in + * `action-execution.ts` that the ladder check could not see: a live comparison + * in `seedFlowActionParams`, a warn-once log key in `enforceActionParams`, and + * a docblock. All three were equal in value and invisible to every test in the + * repo, which is the whole shape #14422 was filed to remove — so the same + * convergence needed the same weld, or the next reader re-inlines one and + * nothing says so. */ import { readFileSync } from 'node:fs'; @@ -89,6 +98,25 @@ function readActionExecutionSource(): string { return readFileSync(join(here, 'action-execution.ts'), 'utf8'); } +/** + * Quote spellings of the object-less key as a bare literal, DERIVED from the + * constant rather than hard-coded. + * + * Deriving it is the point, not a flourish. A hard-coded `'global'` here would + * be a fourth copy of the very literal this file exists to forbid, and it + * would go stale in the same silence the day the constant moves. Derived, the + * guard follows the constant: whatever `GLOBAL_ACTION_OBJECT_KEY` becomes, + * that is the spelling `action-execution.ts` may not write out by hand. The + * re-inlining it catches is caught at the moment it happens, while the two + * spellings are still equal — which is the only moment a reader can tell they + * were ever meant to be one thing. + */ +const BARE_LITERALS: readonly string[] = [ + `'${GLOBAL_ACTION_OBJECT_KEY}'`, + `"${GLOBAL_ACTION_OBJECT_KEY}"`, + `\`${GLOBAL_ACTION_OBJECT_KEY}\``, +]; + describe('standalone-action owner key — half B: one spelling (#14422)', () => { it('keeps no ladder body of its own in action-execution.ts', () => { const src = readActionExecutionSource(); @@ -112,3 +140,27 @@ describe('standalone-action owner key — half B: one spelling (#14422)', () => expect(body[1].trim()).toBe('return standaloneActionOwnerKey(action);'); }); }); + +describe('standalone-action owner key — half C: no bare literal (#14678)', () => { + it('spells the object-less key as the CONSTANT everywhere in action-execution.ts', () => { + const src = readActionExecutionSource(); + + // Anti-vacuity, twice over. An empty read, or a file that does not + // import the constant at all, would make every negative below pass for + // exactly the wrong reason — the can-never-fail property this whole + // file was written to replace. Both controls are positive assertions + // against text the converged file must carry. + expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY'); + expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY'); + + for (const literal of BARE_LITERALS) { + expect( + src.includes(literal), + `action-execution.ts writes the object-less action key as the bare literal ` + + `${literal}. It is equal in value to GLOBAL_ACTION_OBJECT_KEY today and parts ` + + `from it in silence the day the constant moves (#14422, #14678). Import the ` + + `constant — this file already does — and compare or interpolate that instead.`, + ).toBe(false); + } + }); +});