Skip to content

Commit 50616d9

Browse files
authored
feat(spec,cli): warn the author when a discarded action alias costs them a handler (#3743) (#3838)
#3742 made `target` beat the deprecated `execute` alias everywhere and had the `ActionSchema` transform DROP the alias from its output, so "two different scripts for one button" became unrepresentable. What it left behind: an author who declares both slots with different values still loses one of the two handlers they wrote, silently. Per Prime Directive #12 that belongs at authoring time. New advisory rule `action-target-execute-conflict`: an action declaring both slots with different values gets a warning naming both handlers, stating that `target` wins, and giving the one-line fix. Equal values are harmless duplication and stay quiet. It never fails the build. The rule has to run PRE-PARSE, because the parse is what consumes the alias. #3743 proposed the CLI compile pipeline as its home; that window is real but nearly always empty, because `defineStack` parses inside the author's own config module — for a `defineStack` app the alias is gone before `os build` ever loads the module. So the rule lives in `@objectstack/spec` (`lintDeprecatedAliases`) and is wired into both layers that perform the discard: `defineStack` warns before parsing (once per distinct conflict), and `os build`/`os validate` run a pre-parse pass for stacks that skip strict `defineStack` (plain object export, `strict: false`, inline function handlers). Each layer reports only its own discards, so one conflict yields one warning. Behaviour fix in the same contract: #3742 probed for a CALLABLE `target` first, which left a string `target` beside a function `execute` still resolving the alias's way — it bound the alias and overwrote the canonical ref the author wrote. `target` now wins in every string/function combination, matching the `ActionSchema` transform. Also documents the rule in the objectui actions reference, which already stated the precedence while saying nothing about the author finding out.
1 parent c916b97 commit 50616d9

13 files changed

Lines changed: 613 additions & 10 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(spec,cli): warn the author when a deprecated action alias is discarded (#3743)
7+
8+
#3742 made `target` beat the deprecated `execute` alias everywhere and had the
9+
`ActionSchema` transform **drop** the alias from its output, so "two different
10+
scripts for one button" became unrepresentable. What it left behind: an author
11+
who declares both slots with different values still loses one of the two
12+
handlers they wrote, **silently**. Per Prime Directive #12 that belongs at
13+
authoring time, so it is now reported there.
14+
15+
**New rule — `action-target-execute-conflict` (advisory).** An action declaring
16+
both `target` and `execute` with different values gets a warning naming both
17+
handlers, stating that `target` wins, and giving the one-line fix (delete
18+
`execute`). Identical values in both slots are harmless duplication and stay
19+
quiet. It never fails the build: the resulting stack is well-defined — the cost
20+
is a handler that never runs, not a broken artifact.
21+
22+
The rule must run **pre-parse**, because the parse is what consumes the alias:
23+
once `ObjectStackDefinitionSchema` has run there is no `execute` key left to
24+
report. It therefore lives in `@objectstack/spec`
25+
(`lintDeprecatedAliases`, exported from the package root) and is wired into
26+
both layers that perform the discard:
27+
28+
- **`defineStack`** — the dominant authoring path, and the one that consumes the
29+
alias earliest: it parses inside your own config module, so by the time
30+
`os build` loads that module the alias is already gone. It now warns on the
31+
console before parsing (once per distinct conflict per process).
32+
- **`os build` / `os validate`** — a new pre-parse pass covering stacks that
33+
skip strict `defineStack`: a plain object default-export,
34+
`defineStack(…, { strict: false })`, and inline function handlers (`target` is
35+
`z.string()`, so those cannot pass strict `defineStack` and are lowered by the
36+
CLI instead). Both commands lint the same input, so they agree by construction
37+
(#3782).
38+
39+
Each layer reports only its own discards, so one authored conflict produces
40+
exactly one warning however the stack is compiled.
41+
42+
**Behaviour fix in the same contract.** #3742 fixed compile-time precedence by
43+
probing for a *callable* `target` first, which left one combination still
44+
resolving the alias's way: a **string** `target` beside a **function** `execute`
45+
bound the alias and then overwrote the canonical ref the author wrote. `target`
46+
now wins in every combination of string/function across the two slots, matching
47+
the `ActionSchema` transform — so the new warning states one precedence rule
48+
that is true everywhere. If you relied on an inline `execute` function winning
49+
over a string `target`, move it into `target`; the warning names the action.
50+
51+
Authoring is otherwise unchanged: `execute` alone is still accepted, still
52+
lowered into `target`, and still documented.

content/docs/protocol/objectui/actions.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ The `type` field selects how an action is dispatched. The complete enum is **`sc
5353

5454
`target` is the canonical binding for every non-`script` type. The deprecated `execute` field is lowered into `target` during parsing and then **removed from the parsed metadata**, so every consumer — server dispatch and renderer alike — reads exactly one slot. If an action declares both, `target` wins and `execute` is discarded.
5555

56+
Declaring both with *different* values means one of the two handlers you wrote never runs, so it raises an advisory `action-target-execute-conflict` warning naming both — from `defineStack` when it parses your config, and from `os build` / `os validate` for stacks that skip strict `defineStack`. It never fails the build; the fix is to delete `execute`. The same value in both slots is harmless duplication and stays quiet.
57+
5658
### Script Actions
5759

5860
Run logic with no server round trip via `body` (an L1 CEL expression or L2 sandboxed JS). `body` is only meaningful when `type` is `script`, and declaring one on any other type is a parse-time error — those types dispatch on `target`, so the body would never be invoked.

packages/cli/src/commands/compile.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from 'path';
55
import fs from 'fs';
66
import chalk from 'chalk';
77
import { ZodError } from 'zod';
8-
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
8+
import { ObjectStackDefinitionSchema, normalizeStackInput, lintDeprecatedAliases } from '@objectstack/spec';
99
import { loadConfig } from '../utils/config.js';
1010
import { lowerCallables } from '../utils/lower-callables.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
@@ -95,6 +95,53 @@ export default class Compile extends Command {
9595
if (!flags.json) printStep('Normalizing stack definition...');
9696
const normalized = normalizeStackInput(config as Record<string, unknown>);
9797

98+
// 2a. [#3743] PRE-PARSE authoring lint. Everything in the post-parse lint
99+
// block (3d and below) reads `result.data`, which is the wrong side of
100+
// the fence for an alias the pipeline itself consumes: `lowerCallables`
101+
// drops a function-valued `execute` and the `ActionSchema` transform
102+
// drops a string one (#3742), so by then "the author declared both
103+
// slots" is no longer representable. This pass runs on `normalized` —
104+
// before lowering, before parse — where both slots are still as
105+
// written, and it is the same input `os validate` lints, so the two
106+
// surfaces agree by construction (#3782).
107+
//
108+
// It reports the discards THIS pipeline performs. A stack authored
109+
// with strict `defineStack` has already been parsed inside its own
110+
// config module, so its alias was consumed — and warned about — there;
111+
// this pass covers everything that skipped that gate: a plain object
112+
// default-export, `defineStack(…, { strict: false })`, and inline
113+
// function handlers (`target: z.string()` rejects those, so they can
114+
// only reach the pipeline via a non-strict path and are lowered here).
115+
//
116+
// Advisory today; `severity: 'error'` is honoured so a future rule
117+
// gates the build here without further wiring.
118+
if (!flags.json) printStep('Checking deprecated aliases (#3743)...');
119+
const aliasLint = lintDeprecatedAliases(normalized as Record<string, unknown>);
120+
const aliasLintErrors = aliasLint.filter((f) => f.severity === 'error');
121+
const aliasLintWarnings = aliasLint.filter((f) => f.severity !== 'error');
122+
if (aliasLintWarnings.length > 0 && !flags.json) {
123+
console.log('');
124+
for (const f of aliasLintWarnings) {
125+
printWarning(`${f.where}: ${f.message}`);
126+
console.log(chalk.dim(` ${f.hint}`));
127+
console.log(chalk.dim(` rule: ${f.rule}`));
128+
}
129+
}
130+
if (aliasLintErrors.length > 0) {
131+
if (flags.json) {
132+
await emitJson({ success: false, error: 'deprecated alias check failed', issues: aliasLintErrors }, 0, { compact: true });
133+
this.exit(1);
134+
}
135+
console.log('');
136+
printError(`Deprecated alias check failed (${aliasLintErrors.length} issue${aliasLintErrors.length > 1 ? 's' : ''})`);
137+
for (const f of aliasLintErrors) {
138+
console.log(` • ${f.where}: ${f.message}`);
139+
console.log(chalk.dim(` ${f.hint}`));
140+
console.log(chalk.dim(` rule: ${f.rule}`));
141+
}
142+
this.exit(1);
143+
}
144+
98145
// 2b. Lower inline `function` handlers (Hook.handler, top-level
99146
// `functions`) to stable string refs BEFORE Zod parse. This
100147
// guarantees we extract the user's real function identity (Zod's

packages/cli/src/commands/validate.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createRequire } from 'node:module';
66
import { join, dirname } from 'node:path';
77
import chalk from 'chalk';
88
import { ZodError } from 'zod';
9-
import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
9+
import { ObjectStackDefinitionSchema, normalizeStackInput, lintDeprecatedAliases, type ConversionNotice } from '@objectstack/spec';
1010
import { loadConfig } from '../utils/config.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateListViewMode } from '@objectstack/lint';
@@ -601,12 +601,25 @@ export default class Validate extends Command {
601601
const viewRefErrors = viewRefLint.filter((f) => f.severity === 'error');
602602
const viewRefWarnings = viewRefLint.filter((f) => f.severity !== 'error');
603603

604-
const authoringLintErrors = [...flowLintErrors, ...autonumberErrors, ...viewRefErrors];
604+
// Deprecated aliases (#3743). The ONE lint here that reads `normalized`
605+
// rather than `result.data`, and it has to: the parse consumes the alias
606+
// it reports (`ActionSchema` folds `execute` into `target` and drops it),
607+
// so a post-parse read is structurally blind to the conflict. `os build`
608+
// lints the same pre-parse input, so both surfaces see the same findings.
609+
// A stack authored with strict `defineStack` was already parsed — and
610+
// warned about — inside its own config module; this covers the paths that
611+
// reach the CLI with the alias intact.
612+
const aliasLint = lintDeprecatedAliases(normalized as Record<string, unknown>);
613+
const aliasLintErrors = aliasLint.filter((f) => f.severity === 'error');
614+
const aliasLintWarnings = aliasLint.filter((f) => f.severity !== 'error');
615+
616+
const authoringLintErrors = [...flowLintErrors, ...autonumberErrors, ...viewRefErrors, ...aliasLintErrors];
605617
const authoringLintWarnings = [
606618
...flowLintWarnings,
607619
...livenessLint,
608620
...autonumberWarnings,
609621
...viewRefWarnings,
622+
...aliasLintWarnings,
610623
];
611624
if (authoringLintErrors.length > 0) {
612625
if (flags.json) {

packages/cli/src/utils/lower-callables.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,33 @@ describe('lowerCallables — action handler slot precedence (#3713)', () => {
9292
expect(result.count).toBe(0);
9393
});
9494

95+
// #3743: "`target` first" meant "a CALLABLE `target` first", which left one
96+
// combination still resolving the alias's way — a string `target` beside a
97+
// function `execute` bundled the alias and then overwrote the canonical ref
98+
// the author wrote. Same inversion as above, one slot type over.
99+
it('keeps a string `target` when the alias holds an inline function', () => {
100+
const result = lowerCallables({
101+
actions: [{
102+
name: 'mixed',
103+
label: 'Mixed',
104+
type: 'script',
105+
target: 'preferredHandler',
106+
execute: function legacyHandler() { return 'legacy'; },
107+
}],
108+
});
109+
110+
const [action] = actionsOf(result);
111+
expect(action.target).toBe('preferredHandler');
112+
expect(result.count).toBe(0);
113+
// The losing alias still has to go: a function value is not JSON-safe and
114+
// `ActionSchema` expects a string, so leaving it would trade the silent
115+
// discard for a confusing parse error. `lintDeprecatedAliases` is what
116+
// tells the author which handler they lost (#3743).
117+
expect('execute' in action).toBe(false);
118+
expect(() => JSON.stringify(result.lowered)).not.toThrow();
119+
expect(JSON.stringify(result.lowered)).not.toContain('legacy');
120+
});
121+
95122
it('applies the same precedence to actions nested under an object', () => {
96123
const result = lowerCallables({
97124
objects: [{

packages/cli/src/utils/lower-callables.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,10 @@ export function lowerCallables(input: Record<string, unknown>): LoweringResult {
175175

176176
/**
177177
* Lower a single action definition: detect a callable on `target` or on the
178-
* deprecated `execute` alias (canonical `target` first — #3713), register it,
179-
* optionally extract a metadata body, and drop the alias. Mutates a shallow
180-
* clone, never the input.
178+
* deprecated `execute` alias, register it, optionally extract a metadata body,
179+
* and drop the alias. A declared `target` — string or function — always wins
180+
* over `execute` (#3713 / #3743), matching the `ActionSchema` transform. Mutates
181+
* a shallow clone, never the input.
181182
*/
182183
function lowerActionCallable(
183184
raw: unknown,
@@ -197,13 +198,32 @@ function lowerActionCallable(
197198
// (which keeps `target` when both are set). An author who inlined a function in
198199
// both slots got the `execute` one bundled while `action.target = ref` below
199200
// silently overwrote the `target` function they had actually declared.
201+
//
202+
// #3743: "`target` first" means the DECLARED target, not merely a callable one.
203+
// Probing `typeof action.target === 'function'` left one combination still
204+
// resolving the alias's way: a STRING `target` beside a function `execute` fell
205+
// through to the alias, bundled it, and then overwrote the canonical ref the
206+
// author wrote — the same inversion #3713 fixed for the function/function pair,
207+
// one slot type over. `target` now wins in every combination, so the
208+
// `action-target-execute-conflict` warning can state one precedence rule and
209+
// have it be true everywhere.
210+
const targetDeclared =
211+
typeof action.target === 'function' || (typeof action.target === 'string' && action.target.length > 0);
200212
const handlerSlot: 'execute' | 'target' | null =
201213
typeof action.target === 'function'
202214
? 'target'
203-
: typeof action.execute === 'function'
215+
: !targetDeclared && typeof action.execute === 'function'
204216
? 'execute'
205217
: null;
206-
if (!handlerSlot) return action;
218+
if (!handlerSlot) {
219+
// A function-valued alias that lost to a string `target` still has to go: it
220+
// is not JSON-safe (`JSON.stringify` drops it from the artifact without a
221+
// word) and `ActionSchema` expects a string, so leaving it would trade a
222+
// silent discard for a confusing parse error. The lint above has already
223+
// told the author which handler they lost.
224+
if (typeof action.execute === 'function') delete action.execute;
225+
return action;
226+
}
207227
const fn = action[handlerSlot] as AnyFn;
208228
const ref = uniqueName(baseName, taken);
209229
taken.add(ref);

packages/spec/api-surface.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
".": [
3+
"ACTION_TARGET_EXECUTE_CONFLICT (const)",
34
"ADMIN_FULL_ACCESS (const)",
45
"ALL_CONVERSIONS (const)",
56
"AUDIENCE_ANCHOR_POSITIONS (const)",
@@ -35,6 +36,7 @@
3536
"DatasourceMappingRule (type)",
3637
"DatasourceMappingRuleSchema (const)",
3738
"DefineStackOptions (interface)",
39+
"DeprecatedAliasFinding (interface)",
3840
"EVERYONE_POSITION (const)",
3941
"EvalUser (type)",
4042
"EvalUserInput (type)",
@@ -144,11 +146,13 @@
144146
"expandViewContainerWithDiagnostics (function)",
145147
"expression (function)",
146148
"findClosestMatches (function)",
149+
"formatDeprecatedAliasFinding (function)",
147150
"formatSuggestion (function)",
148151
"formatZodError (function)",
149152
"formatZodIssue (function)",
150153
"isAggregatedViewContainer (function)",
151154
"isKnownPlatformCapability (function)",
155+
"lintDeprecatedAliases (function)",
152156
"mapMembershipRole (function)",
153157
"normalizeMetadataCollection (function)",
154158
"normalizePluginMetadata (function)",

packages/spec/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ export { suggestFieldType, findClosestMatches, formatSuggestion } from './shared
107107
export { normalizeMetadataCollection, normalizeStackInput, normalizePluginMetadata, MAP_SUPPORTED_FIELDS, METADATA_ALIASES } from './shared/metadata-collection.zod';
108108
export type { MetadataCollectionInput, MapSupportedField, NormalizeStackInputOptions } from './shared/metadata-collection.zod';
109109

110+
// Pre-parse authoring lint (#3743) — the one window where a deprecated alias is
111+
// still visible, since the parse itself resolves and drops it. `defineStack`
112+
// warns from here; the CLI runs the same rules over stacks that skip it.
113+
export { lintDeprecatedAliases, formatDeprecatedAliasFinding, ACTION_TARGET_EXECUTE_CONFLICT } from './shared/deprecated-aliases';
114+
export type { DeprecatedAliasFinding } from './shared/deprecated-aliases';
115+
110116
// Metadata conversion layer (ADR-0087 D2) — old-shape → canonical-shape transforms applied at load.
111117
export * from './conversions/index.js';
112118

0 commit comments

Comments
 (0)