You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 1371dc9
Browse filesBrowse the repository at this point in the historyBrowse files
feat(lint): os validate refuses an action translation's resultDialog title / description / acknowledge when the action declares no resultDialog (#21304)
Fixes#21264
Clause-②: no (narrowing)
## What changed
`validateTranslationReferences`
(`packages/lint/src/validate-translation-references.ts`) is the rule
behind `translation-target-unknown`, which `os validate`, `os build` and
`os lint` all run. Its action-entry walk (`checkActionEntry`) now judges
the result dialog's prose leaves, `resultDialog.title`,
`resultDialog.description` and `resultDialog.acknowledge`, at both
action addresses, `objects.OBJECT._actions.ACTION` and
`globalActions.ACTION`.
| bundle key | judged against | code · level | the runtime read it
mirrors |
|---|---|---|---|
| `resultDialog.title` / `.description` / `.acknowledge` | whether the
action declares a `resultDialog` at all | `translation-target-unknown` ·
error (**new**) | `resolveActionResultDialog` |
| `resultDialog.fields.PATH` | each literal `resultDialog.fields[].path`
| `translation-target-unknown` · error (unchanged) |
`resolveActionResultDialog` |
- One finding per leaf, at the level the walk already uses for an
undeclared target. No second vocabulary.
- Under a declared dialog every leaf passes, including a leaf the dialog
does not set itself. The resolver overlays a translated `title` onto a
dialog that declares no `title`, so declaring the dialog is the whole
condition.
- The action-entry coverage pin (`coverage vs the action translation
shape`) moves the three leaves from `leaf-copy` to `reference-checked`.
Each is backed by a ghost that must report at both addresses, under a
new host `noDialog` (an action that declares no `resultDialog`).
`resultDialog` itself stays a `container`.
- Docs: the rule table in
`content/docs/protocol/kernel/i18n-standard.mdx` gains every keyed child
of an action entry the walk judges: `params.NAME.options.VALUE`,
`outcomeMessages.OUTCOME`, `resultDialog.fields.PATH` and this card's
leaves. A lead sentence says these rows hold at both action addresses.
The `globalActions` row states only what `os validate` does today: it
resolves when it names an action with no `objectName`, and a key naming
a bound action is an error whose message names the
`objects.OWNER._actions.ACTION` key to write instead, OWNER being the
action's own `objectName`. It says nothing about where the copy is read
from; both resolvers still fall back to `globalActions.ACTION` for a
bound action until #21261 lands.
- Changeset: `.changeset/21264-result-dialog-leaves.md`,
`@objectstack/lint` `minor`, with `Clause-②: no (narrowing)`, a BREAKING
banner heading its "What changes for a project" paragraph, and one
ADR-0087 disposition, `not-required (no-migration-prescription)`, with
its reason in writing.
## Why
`resolveActionResultDialog`
(`packages/spec/src/system/i18n-resolver.ts`) returns the action's own
`resultDialog` untouched when the action declares none, before any
lookup. So a dialog leaf under such an action is read by nothing, while
`os validate` passed it clean. A bundle can translate a dialog the
action declares. It cannot add one.
## Measured
All readings below are on this branch, `objectstack-ai/objectstack`. The
base is `origin/main` `4e6dc2338a`, and HEAD is `46b40dc845`.
**The door, `os validate --json`, before and after.** The probe stack
has a bound action `plain_probe` with no `resultDialog`. Its bundle
carries `title`, `description` and `acknowledge` under `resultDialog`,
plus a `params.ghost` control. A bound action `dialog_probe` declares a
dialog with no `title`, and its bundle carries all three leaves. An
object-less `plain_global` with no dialog carries `resultDialog.title`.
An object-less `dialog_global` declares a dialog and carries all three
leaves.
| key | base `4e6dc2338a` | HEAD `46b40dc845` |
|---|---|---|
| `..._actions.plain_probe.params.ghost` (control) | error | error |
| `..._actions.plain_probe.resultDialog.title` / `.description` /
`.acknowledge` | no finding | 3 errors |
| `globalActions.plain_global.resultDialog.title` | no finding | error |
| `..._actions.dialog_probe.resultDialog.*` (declared dialog) | no
finding | no finding |
| `globalActions.dialog_global.resultDialog.*` (declared dialog) | no
finding | no finding |
Before: 1 error. After: 5 errors, and both runs exit 1.
**The runtime read.** `translateAction` (`@objectstack/spec/system`,
dist), on the same bundle in `zh-CN`:
- `plain_probe` and `plain_global` return no `resultDialog`, so the
leaves are never read.
- `dialog_probe` returns `title` `令牌已生成`, `description` `请妥善保存` and
`acknowledge` `已保存`, though its own dialog sets no `title`. The field
label `token` is `令牌`.
- `dialog_global` returns `已生成`, `全局说明` and `好的`.
So the control is a real read.
**Census.**
- `os validate --json` on each example app at HEAD: `app-crm` exit 0,
`app-todo` exit 0, `app-showcase` exit 0, `app-multi-package` exit 0.
That is 0 findings on any `resultDialog` path.
- The rule run over `@objectstack/platform-objects`' 50 objects and its
shipped four-locale bundle (dist): the bundle carries 108 dialog leaf
keys, from 9 actions × 3 leaves × 4 locales. All 108 sit under a
declaring action. There are 0 findings on a `resultDialog` path, and 16
findings in total, the same total #21216's dev read.
- `git grep` over the 98 tracked translation-bundle files:
`resultDialog` appears only in `platform-objects`' four locale files. No
example bundle carries one.
**Ablation.** Each leg is committed first, then mutated through
`scripts/ablation-replace.mjs`, which uses a literal anchor, proves the
mutation is on disk by blob hash, and restores with `git checkout HEAD`.
The test imports the rule from `src` by a relative path, so no `dist`
build sits between the mutation and the run.
- Leg 1 drops the new leg's call,
`checkActionResultDialogLeaves(findings, ctx);`. Blob `de2dc9a541a9`
becomes `8133e71705ce`. 3 tests fail and 149 pass: both new refusals,
and the coverage pin's backing test. Restored: blob equals HEAD and `git
diff HEAD` is empty.
- Leg 2 drops the declared-dialog guard, `if
(isRec(ctx.action.resultDialog)) return;`. Blob `de2dc9a541a9` becomes
`1892f4a64d54`. 4 tests fail and 148 pass: this card's declared-dialog
control, the `resultDialog.fields` control (`accepts every declared
path…`), `accepts the real instance of every keyed group…`, and the
existing dotted-path refusal. Each of the four carries a translated leaf
under a declared dialog. Restored the same way.
- The direction is the usual one: the tests turned red.
## Tests (at HEAD `46b40dc845`)
- `pnpm --filter @objectstack/lint test`: 119 files and 5518 tests pass.
- `pnpm --filter @objectstack/lint typecheck`: exit 0, including
`check:test-typecheck`.
- `pnpm --filter @objectstack/cli exec vitest run --project unit`: 243
files and 3439 tests pass. The `integration` layer is left to CI,
because this diff touches no spawn entry.
- Filter direction: downstream (`...@objectstack/lint`), narrowed to
`@objectstack/cli`, the `os validate` door the dispatch names. No other
importer owes tests, because `@objectstack/lint`'s export surface is
byte-unchanged: the new function is module-private.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` (no paths) derives 88 commands, and all 88 exit 0. `--ran`
reconciles 88 derived, 88 run, 0 NOT-MEASURED, with every exit code
recorded.
- Two gates first answered `PREREQUISITE NOT MET` (exit 3):
`check:skill-examples` (no `client-react` dist) and
`check:dual-build-cjs-loads` (8 packages with no dist). They passed once
those packages were built.
- origin/main moved to `5a9292e6f6` during the run, and only
`.github/workflows/ci.yml` changed among the derivation's inputs. A
derivation from a tree at `5a9292e6f6`, given this diff's 4 paths,
yields the same 88 commands.
- ESLint, narrowed and measured. The population, read from
`eslint.config.mjs`: only `ts`, `tsx`, `mts`, `cts`, `js`, `jsx`, `mjs`
and `cjs` globs are linted, so of this diff's 4 paths the 2 `.ts` files
are the population. Running `eslint --no-inline-config --format json` on
them gives 2 files, 0 errors and 0 warnings. Invariance: the config
enables no type-aware linting (no `parserOptions.project`, no typed
rules), so this diff cannot move a verdict on an untouched file. The
full `pnpm lint` is left to CI.
- Round 2 (head `b82d990cb9`: the changeset and the docs row only; code
and tests unchanged since `46b40dc845`): `node
scripts/check-adr-0087-registration.mjs --base origin/main` exits 1 with
the arm corrected and no marker (signals BREAKING and
clause-②-narrowing), and exits 0 with the marker. `node
scripts/check-changeset-no-major.mjs --base origin/main` exits 0. With
this body passed as the `pull_request` event (`--event`), it also exits
0 and its level axis reads `no (narrowing)`, a BREAKING change that
ships `minor` during the launch window. The 40 families
`dispatch-gates.mjs` derives for the `.mdx` path all exit 0, as do the
changeset path's `check-empty-changeset`,
`check:changeset-gate-self-tests`, `check:objectui-changeset`,
`check:pm-changeset-deadline-census` and `check:published-files`.
## Acceptance notes
- **`skills/objectstack-i18n/SKILL.md` (Tier H, `domain:skills`, a
separate PR, not edited here).** It would need three changes:
- The `_actions.{action_name}` row of its sub-key table (`:164`) lists
`params.{param_name}` and `resultDialog` only. It should also list
`params.{param}.options.{value}`, `outcomeMessages.{outcome}`,
`resultDialog.title` / `.description` / `.acknowledge` and
`resultDialog.fields.{path}`.
- The orphan paragraph (`:198`-`:202`) enumerates what
`translation-target-unknown` names. It should add an action's undeclared
outcome, an undeclared result-dialog field, and a result dialog the
action does not declare.
- `globalActions` (`:170`) should state what `os validate` does: a key
naming a bound action is an error whose message names the object-scoped
key to write instead.
- The `Clause-②` line reads `no (narrowing)`: this diff narrows what `os
validate` accepts and widens nothing. Spelled that way,
`check:adr-0087-registration` asks for a disposition. PR #21258's landed
changeset carries the same bare `yes` this PR first copied; that is left
to the dispatching seat.
---------
Co-authored-by: Claude <noreply@anthropic.com>
`os validate`, `os build` and `os lint` now check an action translation's result-dialog copy against whether the action declares a `resultDialog`, under both `objects.OBJECT._actions.ACTION` and `globalActions.ACTION`.
6
+
7
+
Clause-②: no (narrowing)
8
+
9
+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, export or stored value moves: TranslationDataSchema parses these three keys exactly as before, so objectstack migrate meta has nothing to convert, and the runtime publish door never runs this rule on a translation write (the member's runtime types default to flow). The refusal is a lint finding at the authoring doors that names the key; its remedy is the author's choice between moving the copy, declaring the dialog or deleting the copy, not a mechanical rewrite of one shape into another. -->
10
+
11
+
**What is refused.**`resultDialog.title`, `resultDialog.description` and `resultDialog.acknowledge` under an action that declares no `resultDialog` are now `translation-target-unknown` errors, one per key: the code and level an undeclared `params`, `outcomeMessages` or `resultDialog.fields` key already gets. `translateAction` returns no dialog for such an action, so the copy is never read. Before this, only `resultDialog.fields.PATH` was checked under the dialog, and these three keys passed.
12
+
13
+
**What still passes.** The same three keys under an action that declares a `resultDialog` are read and pass, whether or not the dialog sets that text itself.
14
+
15
+
**BREAKING** — an accept-set narrowing at the `os validate`, `os build` and `os lint` doors, shipped as `minor` under the launch-window convention. **What changes for a project.** A bundle that carries one of these keys under an action with no `resultDialog` now fails `os validate` with exit 1 instead of passing, and `os build` refuses it. The fix is to move the keys under the action that declares the dialog, declare the `resultDialog` on the action if it should show one, or delete the keys. The four example apps (`app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) and the bundle shipped with `@objectstack/platform-objects` produce no finding on these keys, so none of their exit codes change.
Copy file name to clipboardExpand all lines: content/docs/protocol/kernel/i18n-standard.mdx
+10-2Lines changed: 10 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1036,6 +1036,10 @@ name, the package's own record is the one a field's options, an action's params,
1036
1036
a dashboard's widgets and header actions, or a flow's screens are judged against;
1037
1037
every other name the two declare adds up.
1038
1038
1039
+
A row that starts `{action}.` holds at both action addresses,
1040
+
`objects.{object}._actions.{action}` and `globalActions.{action}`: the keys
1041
+
beneath an action entry are judged the same way under either.
1042
+
1039
1043
| Key | Resolves when it names |
1040
1044
|:---|:---|
1041
1045
|`objects.{object}`| an object declared by this stack **or a sibling package of the same artifact** — its keys are then judged row by row below; **or** a platform object, or an object an `objectExtensions[]` entry extends that no package of the artifact declares — both skipped whole, because the owner's fields are not visible from here |
@@ -1046,8 +1050,12 @@ every other name the two declare adds up.
1046
1050
|`objects.{object}._tabs.{tab}`| a filter-preset tab's `name` in `interfaceConfig.userFilters.tabs` on a page over that object (`interfaceConfig.source`, else the page's `object`) — a list view's own `tabs` has no renderer and is not resolvable |
1047
1051
|`objects.{object}._validations.{rule}`| a validation rule's `name` in that object's `validations[]` or in an `objectExtensions[]` entry aimed at it — a `conditional` rule's `then` / `otherwise` branch included |
1048
1052
|`objects.{object}._actions.{action}`| an action bound to that object: one in its `actions`, or a stack-level action whose `objectName` is that object |
1049
-
|`objects.{object}._actions.{action}.params.{param}`| a param's `name` on that action |
1050
-
|`globalActions.{action}`| an action with **no**`objectName` — a key naming a bound action is reported with the `objects.{owner}._actions` key the resolver reads instead |
1053
+
|`globalActions.{action}`| an action with **no**`objectName` — a key naming a bound action is an error whose message names the `objects.{owner}._actions.{action}` key to write instead, `{owner}` being the action's own `objectName`|
1054
+
|`{action}.params.{param}`| a param's `name` on that action |
1055
+
|`{action}.params.{param}.options.{value}`| an option's stored **`value`** in that param's inline `options` — a warning, as on a field. A param that references a `field` and declares no inline `options` takes the field's list when the dialog renders, so its option keys are not judged |
1056
+
|`{action}.outcomeMessages.{outcome}`| an outcome the action's own `outcomeMessages` declares |
1057
+
|`{action}.resultDialog.fields.{path}`| the literal `path` of a field the action's `resultDialog.fields[]` declares — dots included, so `client.secret` is one key |
1058
+
|`{action}.resultDialog.title` / `.description` / `.acknowledge`| an action that declares a `resultDialog`, whether or not the dialog sets that text itself — for an action that declares none, nothing reads the dialog's copy |
1051
1059
|`apps.{app}`| an app declared, or one a `navigationContributions` entry anywhere in the artifact contributes into — including an app owned outside the artifact |
1052
1060
|`apps.{app}.navigation.{id}`| a navigation item `id` the app declares (nested `children` and `areas[]` included) or one contributed into it; under an app that is only contributed into, the contributed ids alone |
1053
1061
|`dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}`| a dashboard declared / a widget `id` on it / a header action's `actionUrl` on it |
0 commit comments