diff --git a/.changeset/cli-action-dedup-global-key-constant.md b/.changeset/cli-action-dedup-global-key-constant.md new file mode 100644 index 0000000000..aca3ef454c --- /dev/null +++ b/.changeset/cli-action-dedup-global-key-constant.md @@ -0,0 +1,22 @@ +--- +"@objectstack/cli": patch +--- + +refactor(cli): spell the action-dedup object-less key as `GLOBAL_ACTION_OBJECT_KEY` (#14669) + +`os lint` dedups action declarations on the engine's composite registration key +(`:`), and the object half of that key terminated on a bare +`'global'` string literal in `lintConfig`'s `PREFIXED_TYPES` table. The engine's +own writers stopped spelling the literal: PR #14667 converged +`ObjectQLPlugin.actionObjectKey` onto the shared `GLOBAL_ACTION_OBJECT_KEY` +constant for exactly this reason — a copy that agrees by value today is the one +that parts from the writer in silence the day the constant moves, with no test +in the repo able to see it. This reader now imports the constant from +`@objectstack/objectql`, which `@objectstack/cli` already depends on. + +**No behaviour moves.** `GLOBAL_ACTION_OBJECT_KEY` is `'global'`, so every key +this table builds is byte-identical to the one it built before; the #5510 dedup +suite (`lint-namespace-prefix.test.ts`, 15 declarations over 5 objects) passes +unchanged. Only `objectName` is read, exactly as before — the `object`/`entity` +aliases are still rejected upstream by `ActionSchema`'s strict shape and are +deliberately not admitted here. diff --git a/examples/app-showcase/test/actions.test.ts b/examples/app-showcase/test/actions.test.ts index 4e26bac856..d39ecf96de 100644 --- a/examples/app-showcase/test/actions.test.ts +++ b/examples/app-showcase/test/actions.test.ts @@ -106,7 +106,8 @@ describe('showcase actions — the object-less (`global`) specimen', () => { const runner = new QuickJSScriptRunner(); it('declares no object, so it keys at `global` (framework#3913)', () => { - // This mirrors ObjectQLPlugin.actionObjectKey / AppPlugin's + // This mirrors `standaloneActionOwnerKey` (`@objectstack/objectql`, the + // helper the ObjectQL plugin calls) / AppPlugin's // `action.object || 'global'`: neither field set → the 'global' bucket. const a = PortfolioSnapshotAction as { objectName?: string; object?: string }; expect(a.objectName).toBeUndefined(); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 5690b98f61..85e8018828 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -5,6 +5,7 @@ import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import { normalizeStackInput, type ConversionNotice } from '@objectstack/spec'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { GLOBAL_ACTION_OBJECT_KEY } from '@objectstack/objectql'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.js'; import { lintDataModel, runAuthoringRules } from '@objectstack/lint'; @@ -295,20 +296,26 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue { key: 'dashboards', label: 'Dashboard' }, { key: 'flows', label: 'Flow' }, // An action's engine registration key is `:`, NOT the - // bare name: `ObjectQLPlugin.actionObjectKey` (and the runtime's - // `standaloneActionObjectName`, kept in lockstep with it) resolve the - // object half to `objectName`, falling back to the canonical object-less - // key `'global'` (#3913). So one package legitimately declaring - // `log_call` on each of five objects occupies five distinct keys and - // nothing shadows anything — deduping those on the bare name produced 12 - // fixed false positives per `objectstack lint` run on HotCRM, growing - // linearly with the object count (#5510), and "just rename one" would have - // broken the shared i18n keys that shape depends on (#592). + // bare name: `standaloneActionOwnerKey` in `@objectstack/objectql` — the + // single implementation, called directly by the ObjectQL plugin and + // re-exported by the runtime, whose `standaloneActionObjectName` is now a + // delegating alias for it — resolves the object half to `objectName`, + // falling back to the canonical object-less key `GLOBAL_ACTION_OBJECT_KEY` + // (`'global'`, #3913). So one package legitimately declaring `log_call` on + // each of five objects occupies five distinct keys and nothing shadows + // anything — deduping those on the bare name produced 12 fixed false + // positives per `objectstack lint` run on HotCRM, growing linearly with the + // object count (#5510), and "just rename one" would have broken the shared + // i18n keys that shape depends on (#592). // - // `'global'` rather than an inert sentinel like `''` is deliberate: it is - // the literal the engine really registers under, so an action declared on - // an object actually NAMED `global` and an object-less action of the same - // name collide for real — and are reported, as they must be. + // `GLOBAL_ACTION_OBJECT_KEY` rather than an inert sentinel like `''` is + // deliberate: it is the key the engine really registers under, so an action + // declared on an object actually NAMED `global` and an object-less action + // of the same name collide for real — and are reported, as they must be. + // It is spelled as the imported constant rather than a bare `'global'` + // literal so this reader cannot part from the engine's writer in silence + // the day the constant moves — the same divergence #14667 removed from the + // plugin's own copy. // // Only `objectName` is read. `object`/`entity` are rejected outright by // `ActionSchema`'s strict shape with a rename prescription, so they never @@ -317,8 +324,13 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue { key: 'actions', label: 'Action', - registryKey: (item, name) => - `${typeof item?.objectName === 'string' && item.objectName ? item.objectName : 'global'}:${name}`, + registryKey: (item, name) => { + const objectKey = + typeof item?.objectName === 'string' && item.objectName + ? item.objectName + : GLOBAL_ACTION_OBJECT_KEY; + return `${objectKey}:${name}`; + }, }, { key: 'reports', label: 'Report' }, { key: 'datasets', label: 'Dataset' }, diff --git a/packages/cli/test/lint-namespace-prefix.test.ts b/packages/cli/test/lint-namespace-prefix.test.ts index 7c570629e8..2143737c73 100644 --- a/packages/cli/test/lint-namespace-prefix.test.ts +++ b/packages/cli/test/lint-namespace-prefix.test.ts @@ -126,10 +126,11 @@ describe('lint — intra-package duplicate-name advisory (ADR-0048 §3.4)', () = describe('lint — actions dedup on the composite engine key, not the bare name (#5510)', () => { // The engine registers an action under `:` - // (`ObjectQLPlugin.actionObjectKey`; the runtime's - // `standaloneActionObjectName` is kept in lockstep with it), with the - // canonical object-less key `'global'` (#3913). Deduping on the bare name - // asked a question the registry never asks. + // (`standaloneActionOwnerKey` in `@objectstack/objectql`, which the ObjectQL + // plugin calls directly and the runtime's `standaloneActionObjectName` now + // delegates to), with the canonical object-less key `'global'` + // (`GLOBAL_ACTION_OBJECT_KEY`, #3913). Deduping on the bare name asked a + // question the registry never asks. const ACTIVITY_ACTIONS = ['log_call', 'log_meeting', 'schedule_meeting']; const CRM_OBJECTS = ['crm_lead', 'crm_contact', 'crm_account', 'crm_opportunity', 'crm_case'];