Repository navigation
feat(lint): os validate judges every keyed child of an action translation entry against its declaration - #21258
Conversation
…against its declaration outcomeMessages.OUTCOME and resultDialog.fields.PATH under _actions.ACTION and globalActions.ACTION are now judged against the outcomes and result-field paths the action declares (translation-target-unknown, error, as params already is), and params.NAME.options.VALUE against a declared param's inline options (translation-option-key-unknown, warning, as a field's options already is). Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…ranslation entry Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ab0aa4e7e151aa4b51e194d94d0736f66510d945 && git checkout ab0aa4e7e151aa4b51e194d94d0736f66510d945
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 434c6c7cab6aee9c5587e680d7eeeb445a756714 dd39cf5405645b240b36b7aba5f35bdfc66b20cd && git checkout -B drift-repro 434c6c7cab6aee9c5587e680d7eeeb445a756714 && git merge --no-ff dd39cf5405645b240b36b7aba5f35bdfc66b20cd
node scripts/docs-audit/affected-docs.mjs --json 434c6c7cab6aee9c5587e680d7eeeb445a756714 |
Contract reviewServed-tier: PR #21258 for card #21216. The head is the tip of ① Derived judgmentsAccept-set changes the diff implies, each judged against the triage direction (
Finding messages, each sentence TRUE against the read sites: "Only the outcomes the action declares are translated, so this copy is never shown" (the resolver's loop); "declares no ② Semver level
Changeset prose, sentence by sentence: " PR-body prose: the table and the four "where the declared sets come from" bullets match the code; " ③ Boundary flagsDev deviations (
Open question 1 — answered by the seat; closed as above. Out-of-scope findings:
Docs, outside this review's gate (prose is the seat's ACCEPT, per contract-review.md) and noted for it: the rule table in Check-runs on Implemented-by: VERDICT: PASS |
…title / description / acknowledge when the action declares no resultDialog (objectstack-ai#21304) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
Fixes #21216
Clause-②: yes
What changed
validateTranslationReferences(packages/lint/src/validate-translation-references.ts) is the rule behindtranslation-target-unknown, andos validate,os buildandos lintall run it. It now judges every keyed child of an action's translation entry against what the action declares. Both addresses are covered:objects.OBJECT._actions.ACTIONandglobalActions.ACTION. Before this change the walk (checkActionParams) judgedparams.NAMEand nothing else.params.NAMEparams[].name, falling back tofieldtranslation-target-unknown· error (unchanged)translateActionParamsoutcomeMessages.OUTCOMEoutcomeMessagestranslation-target-unknown· error (new)resolveActionOutcomeMessagesresultDialog.fields.PATHresultDialog.fields[].pathtranslation-target-unknown· error (new)resolveActionResultDialog, and objectui'sactionResultDialogparams.NAME.options.VALUEoptions[].valuetranslation-option-key-unknown· warning (new)translateActionParams, and objectui'sactionParamOptionLabelparams' own code and severity. Option keys reuse the code and severity a field'soptionskey already gets (checkOptionKeys), including the "keyed by the display label, rename it to the value" diagnosis. The module's Severity note explains why option keys stay warnings: such a key names something real, and the fix is a rename.packages/spec/src/system/i18n-resolver.ts):resolveActionOutcomeMessagesloops overObject.keys(action.outcomeMessages). When the action declares nooutcomeMessages, it returnsundefinedbefore reading any bundle key. So for such an action every bundle outcome is unread, and the lint refuses each one.resolveActionResultDialogmaps over the declaredfields[]and looks upfields[field.path]. With noresultDialogit reads nothing. With a dialog that declares nofields, it reads no label. objectui'suseObjectLabel.actionResultDialoguses the same keys.optionsarray. The spec resolver overlays only that array. objectui resolvesparam.optionsfirst and falls back to the referenced field's options, so an authored array wins. A field-backed param with no inline array gets its list from the field when the dialog renders. This rule does not re-derive that inheritance, so those keys are deliberately left unjudged.resultDialog.fields["client.ghost"].localePathnow uses the samememberPathhelper, and its output is byte-identical.ObjectTranslationDataSchemaandTranslationDataSchema(both addresses), down intoparams.NAME.*andresultDialog.*, and holds that key set equal to a classification ledger. Everyreference-checkedkey needs a ghost that reports at both addresses. When the next keyed group joins the shape with no leg, this pin fails. That gap is howoutcomeMessagesshipped unjudged.Measured at the public door (
os validate --json)The probe stack is a
defineStackapp withdelete_probe(bound, declares outcomearchived, paramsreason, andmodewith inline optionfast),reveal_probe(bound,resultDialog.fields[].path=client.secret),check_probe(object-less, outcomeup_to_date) andreveal_global(object-less, pathtoken). Itszh-CNbundle carries each declared key and one undeclared sibling. The CLI closure was built at each tree.be5a83cf(cleanmain)ac847edd/dd39cf54_actions.reveal_probe.resultDialog.fields["client.ghost"]translation-target-unknownerrorglobalActions.reveal_global.resultDialog.fields.ghost_tokentranslation-target-unknownerror_actions.delete_probe.outcomeMessages.restoredtranslation-target-unknownerrorglobalActions.check_probe.outcomeMessages.staletranslation-target-unknownerror_actions.delete_probe.params.mode.options.slowtranslation-option-key-unknownwarning_actions.delete_probe.params.not_a_param(control: the door is reached)translation-target-unknownerrorarchived,up_to_date,client.secret,token,fast,reason(controls)Consumers whose verdict could move (direction: downstream of
@objectstack/lint)os validate --jsonover the four example apps, after the change:app-crmexit 0,app-todoexit 0,app-showcaseexit 0,app-multi-packageexit 0. Each has 0 findings on anoutcomeMessages,resultDialog.fieldsorparams.NAME.optionspath, and 0 translation-rule findings at all. These legs judged nothing before this change, so any finding on those paths would have been new.@objectstack/platform-objectsships (SetupAppTranslations: 4 locales, 50 objects) was run through the rule. It carries 64resultDialog.fieldskeys and 44 param-option keys, and none of them is flagged. It carries 0 outcome keys. Docs andskills/**hold no translation bundle with these keys. No published bundle needed a fix.Tests
All runs are at
ac847edd. The lint source indd39cf54is byte-identical; that commit adds only the changeset.pnpm --filter @objectstack/lint test: 119 files / 5517 passed.pnpm --filter @objectstack/lint typecheck(tsc pluscheck:test-typecheck): exit 0. The test-layer ledger holds 2 files / 6 errors, unchanged.pnpm --filter @objectstack/cli exec vitest run --project unit, atdd39cf54, in two shards: 122 files / 1861 passed, then 121 files / 1578 passed. Theintegrationtier is left to CI, because no integration-tier file or spawn entry is touched.validate-translation-references.test.ts: 15 cases. An undeclared outcome is refused at_actions.ACTIONand atglobalActions.ACTION. Declared outcomes pass at both addresses (the control). An action with nooutcomeMessageshas every outcome refused. The same three shapes coverresultDialog.fields, including the bracketed dotted path. Param options cover a value miss, a label key, a param with neither options nor field, the field-backed skip, and an undeclared param reported once. The coverage pin adds 3 cases.Reverse verification (committed first, then mutated through
scripts/ablation-replace.mjsin WRAP mode, with a restore trap; every restore was proven by blob hash equal toHEADand an emptygit diff HEAD):outcomeMessagesandresultDialog.fieldscalls: 7 failed / 142 passed. Those are the 6 refusal cases plus the coverage pin's "backs every reference-checked claim". The 2 control cases stay green.srcthrough a relative import, so nodist/leg applies. The door probe's@objectstack/lintdist was checked withscripts/ablation-dist-preflight.mjs @objectstack/lint checkActionResultDialogFields: the marker is present in 4 built files and the tree is clean.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 60 families from the merge basebe5a83cf, and all 60 were run atdd39cf54.--ranreports 60 run, 0 NOT-MEASURED, 0 UNRUN, and every exit is 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 unrelated packages had nodist/). After a turbo build of those 8, it was re-run and exited 0. The derivation flagged a stale tree:origin/mainis 4 commits ahead, andscripts/check-route-envelope.mjsandscripts/engine-double-contract.pinned.jsonchanged upstream. Neither family reads the files this PR edits. The 6 value-bearing CI-only invocations (shard attestation, test completeness,check-issue-citations --census) are NOT MEASURED here because they are CI-only.ESLint was narrowed to the two edited files. Their population comes from eslint's own config:
--print-configresolves a config for each. The JSON output reports 2 files, 0 errors and 0 warnings. Type-aware linting is not enabled (eslint.config.mjssets noparserOptions.project), so this diff cannot change the verdict on any untouched file.Acceptance notes
Both of these were measured at the door with a second probe and reported to the PM rather than fixed here.
globalActions.ACTIONkey is refused with "This key is never read".translateAction(vialookupActionField) and objectui'sactionSuffixesboth fall back toglobalActions.ACTIONfor a bound action, and that probe's label resolves. So the lint's message and the resolvers disagree, and this PR touches neither side._actions.ACTION.resultDialog.title,.descriptionand.acknowledgeon an action that declares noresultDialogpass silently, and nothing reads them. These are leaf copy on an undeclared node, not a keyed child, so the coverage pin classifiesresultDialogas a container and this PR leaves them unjudged.Generated by Claude Code