Repository navigation
fix(lint): translation-target-unknown resolves the two declared contribution surfaces - #19060
Conversation
…ibution surfaces
`translation-target-unknown` built its universe from a stack's authored
collections alone, so a locale key for something a package CONTRIBUTES
into metadata another package owns was reported as an orphan at `error`
— the run failed, and the remedy ("or drop it") deleted a translation the
runtime honours. Two surfaces, both declared, both measured:
- `objectExtensions[]`-injected fields and validation rules. Probe
stack `objects: [crm_lead { name }]` + `objectExtensions: [{ extend:
'crm_lead', fields: { sla_tier } }]` reported 1 error at
`…objects.crm_lead.fields.sla_tier`; a genuinely undeclared field on
the same stack reported a finding identical but for the name, so a
correct author and a real typo were indistinguishable in the output.
- the app-name rung. A contributor package shipping the labels for the
nav items it contributed was told the app is one "which this stack
does not define", in `os build`'s per-package leg, whether or not the
app's owner was an entry of the same artifact.
Both folds widen what a key may RESOLVE against and nothing else. The
controls stay lit and pinned: a typo on an extended object, a `_validations`
name no layer declares, an object neither defined nor extended, an app
neither defined nor contributed into, and a contributed id nothing
contributes all still report at `error` with the rule id intact.
Two bounds worth reading before widening either fold further:
- the extension fold is exactly two rungs wide because
`ObjectExtensionSchema` is: `views`, `listViews`, `actions`,
`fieldGroups`, `sections`, `tabs` and `hooks` are refused by name at
the extension level, which a new case pins so the sizing cannot
silently stop being true.
- an extension target this stack does not DEFINE is rung 2b: the object
key resolves and the subtree is skipped wholly, for the reason rung 2
skips a platform object's — the owner's field set is not visible from
here, and judging it against the injected names alone would report the
owner's own field keys.
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
Co-authored-by: Claude <noreply@anthropic.com>
…unknown fold A behaviour fix in a released package, so `patch` rather than `skip-changeset`: the rule ships inside `@objectstack/lint`'s `dist` and its verdict is what `os build` / `os validate` / `os lint` print, so the sentence an upgrading author greps belongs in the CHANGELOG. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 eb12fdb74b85e3fc4828a695ec829dea8c0bd46d && git checkout eb12fdb74b85e3fc4828a695ec829dea8c0bd46d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7af6983ccf99f02cb3f3cdf6296f4691922f8298 fb365260704eb4357f314975592413f5c9fb61e5 && git checkout -B drift-repro 7af6983ccf99f02cb3f3cdf6296f4691922f8298 && git merge --no-ff fb365260704eb4357f314975592413f5c9fb61e5
node scripts/docs-audit/affected-docs.mjs --json 7af6983ccf99f02cb3f3cdf6296f4691922f8298
|
…'s packages[] provide (objectstack-ai#19347) Fixes objectstack-ai#19064 `translation-target-unknown`'s OBJECT rung built its universe from `stack.objects` alone while this rule's own docblock already declared the wider reach `validateObjectReferences`' `artifactProvidedObjectNames` uses. The declaration and the implementation disagreed inside one file, and on `os build`'s per-package leg the author paid for it. Clause-②: no ## The measurement, re-taken on `origin/main` before anything was touched PR objectstack-ai#19060 rewrote this very file on 2026-09-20, so the card's premise was re-measured first-hand at `74fb2f7a80` rather than inherited. Every reading below is a PRINT of every hit, never a count: ``` $ git grep -n -E 'stack\.objects|manifest\.objects' -- packages/lint/src/validate-translation-references.ts :752 * registry merges the layers. So a universe built from `stack.objects` alone :851 for (const obj of recordsOf(stack.objects)) { ← the OBJECT rung's only universe ⇒ `manifest.objects`: ZERO hits. The query asks for BOTH tokens, so the absence is a reading. LIT CONTROL — same query form, same corpus, the rule that already closed this shape: $ git grep -n 'artifactProvidedObjectNames' -- packages/lint/src/ validate-object-references.ts:163 function artifactProvidedObjectNames(stack: AnyRec) validate-object-references.ts:210 for (const n of artifactProvidedObjectNames(stack)) resolvable.add(n); validate-translation-references.ts:692 the docblock that CLAIMS the reach — and nothing that implements it ``` ⇒ the instrument finds the token where it exists, so its absence in the translation rule is a reading and not a dark grep. **The diagnostic, re-run rather than relayed** (the probe is `examples/app-multi-package`'s shape: `core` owns `crm_account`, `orders` translates it, judged on the per-package leg `packageBodyAsStack(body, entries)` builds): ``` translations[0]["zh-CN"].objects.crm_account error · translation-target-unknown Translations are keyed to "crm_account", which no object in this stack defines. The resolver looks up keys derived from the metadata, so this whole subtree is dead weight … Rename the key to the object it was written for, drop it, or ignore this if the object is contributed by another installed package. Defined objects: crm_order. ``` That is the remedy that deletes a translation the runtime resolves, at `error`, so the run FAILS on it — and it is byte-identical, but for the name, to what a genuine typo yields on the same stack.⚠️ The card body still carries `Blocked-by: objectstack-ai#18442`. Re-taken here: objectstack-ai#18441 and objectstack-ai#18442 read `closed` / `completed` and objectstack-ai#19060 landed as `6f8d751bc1`. The line is stale; it is not a reason to stop. ## The precedent — followed, and the judgement the card asked for `validateObjectReferences` closed this exact shape on this exact carrier for object NAMES (objectstack-ai#16611). **It cannot be reused as-is, and the difference is the RETURN, not the reach**: | | `artifactProvidedObjectNames` (objectstack-ai#16611) | `artifactProvidedObjects` (here) | |:--|:--|:--| | carrier | `packages[].manifest.objects` | the same, unchanged | | entry shape | ADR-0130 D4 `{ manifest: body }`, no readable body contributes nothing | the same | | returns | `string[]` — that rule asks only whether a reference RESOLVES | the RECORDS — this universe is keyed by FACTS (the fields, options, views, sections and rules a key may address beneath `objects.NAME`) | A name-only fold here would resolve the object key and then judge the owner's own field keys against an EMPTY fact set — reporting every one of them as an orphan, which is the same false positive one level up and the mistake `objectExtensionsByTarget` records for a target it cannot see. A wholesale subtree SKIP (rung 2b's answer) is wrong for the opposite reason: rung 2b skips because the owner's field set is genuinely invisible from a package that only EXTENDS the target, whereas a sibling's declaration is fully readable here — skipping would leave the per-package leg unable to see a typo the union leg reports. So the records are folded through the SAME collector the declaration loop uses, and the subtree stays judged. Where both the stack in hand and a sibling declare the same field or action name, the record this leg is JUDGING keeps the slot: which layer's `options` a merged field carries is the registry's precedence question, and `checkOptionKeys` is the only consumer of the stored definition — the same decision the `objectExtensions` fold took one collection over. ## Both control directions, pinned side by side A false-positive fix without a false-NEGATIVE control is the shape this repo keeps carding, so every pin below is in the same describe block as the acceptance it neighbours: | direction | pin | |:--|:--| | the false orphan is gone | the sibling-declared object key, and its field / option / `_validations` keys, resolve | | ⭐ CONTROL — the context is what does the work | the same bundle, the same package judged ALONE, still errors | | ⭐ NON-DEGENERACY | a name NO entry of the artifact declares is still `error`, and the remedy now enumerates what the artifact provides (`Defined objects: crm_account, crm_order.`) | | ⭐ NON-DEGENERACY, one rung down | a field the sibling does not declare is still `error` under the now-resolved object (`Declared fields: industry, name.`) | | both on ONE bundle | a sibling key beside a typo reports exactly the typo | | the widening is not a path | an entry with no readable body (a segment reference) makes nothing addressable | | no collateral widening | the single-`defineStack` shape is untouched — `objects` is a STACK collection, so there is no `stack.manifest.objects` form to read | ## Reverse verification, from the COMMITTED fix `scripts/ablation-replace.mjs` deleted the fold's two call lines and ran the file's suite, with the mutation proved on disk rather than by an exit code: ``` anchor x1 -> x0 · blob 21d35a8 -> 7995be70fa39 (the mutation LANDED) Tests 5 failed | 100 passed (105) (the expected direction: RED) restore: blob after restore == blob at HEAD (21d35a8) and `git diff HEAD` is empty ``` All five reds are the new pins — the acceptances AND both non-degeneracy controls, whose hint assertions are themselves non-vacuous. ## Checks — real exit codes | command | exit | |:--|:--| | `pnpm --filter '@objectstack/lint^...' build` (dependency closure) | **0** | | `pnpm --filter @objectstack/lint test` — 106 files, 4000 passed / 5 skipped | **0** | | `pnpm --filter @objectstack/lint typecheck` (incl. `check:test-typecheck`) | **0** | | `pnpm lint` (repo-wide `eslint . --no-inline-config`) | **0** | | 53 of the 57 families `scripts/pm/dispatch-gates.mjs --commands` derives for this diff | **0** each | | `pnpm check:docs-transcript-drift` — re-run after `pnpm --filter '@objectstack/lint...' build`, since it derives from the BUILT registry | **0** | ⊘ NOT MEASURED locally, declared to CI — each exited **3**, which these gates define as "nothing was measured", neither a pass nor a finding: `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` all refuse without a whole-repo `pnpm build`. The artifact-roster and whole-tree families the derivation prints as unplaceable are CI's runs, not this PR's. ## Acceptance notes - `content/docs/protocol/kernel/i18n-standard.mdx`'s key table still describes `objects.{object}` as "an object this stack defines, or a platform object". That row already understated the universe after objectstack-ai#18441 added the `objectExtensions[]` target rung and nothing updated it; it is a doc completeness nit on a page this PR does not touch, noted rather than filed, and the next rung card on this rule is the natural carrier. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18441
Fixes #18442
Clause-②: no
Judged against the actual diff, not predicted: two files under⚠️
packages/lint/srcplus one changeset. Nopackages/specpath (0 files), no Zod schema, no closed-set member, and no published export moved — the diff adds exactly one line beginningimport(in the test file,@objectstack/spec/data) and noexportline at all.packages/spec/api-surface-declarations/**: 0 of the 17 shards touched, so this face stays clear of the live three-way collision there.scripts/pm/check-widening-tells.mjs --declaration noover this diff exits 0 but reports 2 files NOT MEASURED — no declared surface coverspackages/lint— so that instrument is not evidence here in either direction, and the declaration above rests on the reading spelled out in this paragraph.The defect, in one sentence per card
translation-target-unknownbuilt its universe from a stack's AUTHORED collections alone, so a locale key for something a package contributes into metadata another package owns was reported as an orphan at severityerror— the run failed — with an orphan's remedy, which deletes a translation the runtime resolves.Measured, with its control, before the fix
Instrument:
validateTranslationReferences(stack)called directly, unit = findings; taken in this worktree atabb01f105(the branch point).objects: [crm_lead { name }]+objectExtensions: [{ extend: 'crm_lead', fields: { sla_tier } }]+ azh-CNkey forsla_tiererrorattranslations[0]["zh-CN"].objects.crm_lead.fields.sla_tier, remedy "Point the key at a declared field, or drop it"zzz_goneon the SAME stackerror, message and remedy identical but for the name ⇒ a correct author and a real typo were indistinguishable in the outputerror, and the remedy now enumeratesname, sla_tierzh-CNkey for a validation rule the same extension merges inerrorat…._validations.sla_requirederroron the object key: "which no object in this stack defines … this whole subtree is dead weight"packageBodyAsStack(contributorBody, artifactPackages), contributor carriesnavigationContributions+translationsand no apps, app owner INSIDE the artifacterrorattranslations[0]["zh-CN"].apps.crm_enterprise: "which this stack does not define", remedy "Match the key to an app'sname, or drop it"error, identicalapps.zzz_not_an_app, neither declared nor contributed intoerrorerror, remedy now listscrm_enterpriseThe #18441 control row is the card's grading in one line: it IS the defect. The fix is judged on that row staying lit, not on the first row going quiet.
The fix
One fold per declared surface, folded into the universe the rule already builds:
objectExtensions[](objectExtensionsByTarget) — the fields and validation rules an extension merges into its target. Read fromstack.objectExtensionsandpackages[].manifest.objectExtensions, for the reason validate / lint / build accept a lookup or master_detail whosereferencenames an object that exists nowhere — the dangling target is found only at runtime #16611 gave for object names: the release artifact is the co-ownership boundary, so a per-package leg that cannot see a sibling package's extension reports the RUN's blind spot as the author's mistake. Without the second carrier the union leg and the per-package leg would disagree about one key.checkNavContributionGroupsyields NOTHING for a contribution whose target app is absent, because 「a package may legally contribute into an app shipped by a DIFFERENT artifact installed separately」 and reporting the absent-app case there 「would refuse the supported cross-artifact case at build time」. A rule that FAILS the run over the translation of such a contribution refuses that same supported case, through the one door an author cannot argue with.Both folds widen what a key may RESOLVE against and nothing else. No severity moved, no path is exempted, no rule was disabled, and nothing was baselined or quarantined.
Two bounds, both measured rather than assumed
ObjectExtensionSchemais. The card's reason for not fixing this in place cited a fold surface of "fields, views, actions, sections, tabs, validations". Measured against the schema (instrument:ObjectExtensionSchema.safeParse, unit = issues, key refusal separated from value rejection bycode === 'unrecognized_keys'at path length 0):fields,validations,indexes,label,pluralLabel,descriptionparse;views,listViews,actions,fieldGroups,sections,tabs,hooksare each refused BY NAME at the extension level. ⇒ onlyfields.*and_validations.*are reachable through an extension, so a_views/_sections/_tabs/_actionskey on an extended object is an orphan exactly as before — pinned, with the schema surface itself pinned beside it so the sizing cannot silently stop being true. The card's DEFECT premise holds; that one sentence of its reasoning was wider than the schema.validate-object-references.ts:objectExtensions[].fieldsis deliberately not walked, because judging it "would refuse the legitimate cross-ARTIFACT case by the rule that exists to catch the typo").Tests — every widening carries a lit control
packages/lint/src/validate-translation-references.test.ts, 20 new cases in two blocks. Both card shapes, and for each: the correct-author case silent, the typo case stillerrorwith the rule id and severity pinned, and the two of them on ONE bundle so "one finding, and it is the typo's" is the assertion.Controls that must keep firing, and do: a typo'd field on an extended object; a
_validationsname no layer declares; an object neither defined nor extended; an app neither defined nor contributed into; a contributed id nothing contributes; an id contributed into app B keyed under app A; an extension entry with noextend(makes nothing addressable); a declared app keeping its own diagnosis, so the new branch cannot leak into the population #18203 covers.Reverse verification — the ablation
Fix committed first (
8be3be14a), then the RULE file alone restored to its pre-fix bytes with the new tests left in place:ca631c6215ef4edd86b51353ec746919f2bb777d= theabb01f105blob, ≠ the HEAD blob952315ff1495647ec1ed2cf093b5b8a9191c7649;grep -cfor both fold markers in the ablated file = 0.extendguard, the "folds no other rung" case, the schema surface pin, and the declared-app diagnosis.git checkout HEAD --(not the bare form, which would have taken the mutation back out of the polluted index), proven bygit diff HEADempty AND the on-disk hash back to952315ff…; re-run from the restored state: 96 passed (96).Local verification
pnpm --filter '@objectstack/lint^...' buildpnpm --filter @objectstack/lint testpnpm --filter @objectstack/lint typechecktsc --noEmit+check:test-typecheck, 2 files / 6 errors / 2 pinned signatures held in the shrink-only ledger, unmoved)pnpm --filter @objectstack/lint buildcheck-dts-emitted: 4/4node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackover the real change set, then every command it printedpnpm lint(eslint . --no-inline-config)⊘ NOT MEASURED, 3 families, each refusing loudly with
PREREQUISITE NOT METrather than passing:check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt— all three read BUILT output of the whole workspace (77+ packages with nodistin this worktree), which is CI'sBuild Core/TypeScript Type Check. ⛔ Neither a pass nor a finding.check:docs-transcript-driftfirst read the same refusal and was then MEASURED afterpackages/lintwas built: exit 0.Changeset
.changeset/18441-translation-target-unknown-contribution-surfaces.md,@objectstack/lint: patch — ⛔ notskip-changeset.@objectstack/lintpublishes (files: [dist, README.md, CHANGELOG.md],publishConfig.access: public) and the rule ships inside thatdist; its verdict is whatos build/os validate/os lintprint, so an author upgrading past a run that used to fail on their correct keys greps the CHANGELOG for exactly this sentence. A behaviour fix in a released package takespatch, and the diff moves nothing that would take more:Clause-②: no, non-breaking, so no ADR-0087 disposition applies.Acceptance notes
Noted, not filed — out of scope for this PR, no card:
#16611gap on this rule's OBJECT rung. Measured here on the per-package leg (packageBodyAsStack): a package translating an object a SIBLING package of the same artifact declares still reports 1erroron the object key, becausebuildUniversereadsstack.objectsand neverpackages[].manifest.objects. It is the shapeartifactProvidedObjectNamesalready closed invalidate-object-references.ts, on a rung neither card names, so it is left alone and reported rather than carried in. Carrier when someone takes it: the same file. Dedupe words:translation-target-unknown,artifactProvidedObjectNames,per-package leg,sibling package object,packageBodyAsStack.translation-target-unknownis warning-only and its rule id carries no namespace, so no app can gate it #16310 promotion of this rule toerror. Not corrected here: a wording change in a header this PR is already editing reads as part of the fold, and nothing mechanical is wrong.Generated by Claude Code