Skip to content

Commit 2b3eb17

Browse files
os-billclaude
andauthored
fix(lint): the translation rule's OBJECT rung reads what the artifact's packages[] provide (#19347)
Fixes #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 #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: #18442`. Re-taken here: #18441 and #18442 read `closed` / `completed` and #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 (#16611). **It cannot be reused as-is, and the difference is the RETURN, not the reach**: | | `artifactProvidedObjectNames` (#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 #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>
1 parent 1a2bb9e commit 2b3eb17

3 files changed

Lines changed: 316 additions & 8 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`translation-target-unknown` no longer reports the locale keys a package ships for an object a SIBLING package of the same artifact declares — the OBJECT rung's universe read `stack.objects` alone, while this rule's own docblock already declared the wider `artifactProvidedObjectNames` reach (#19064).
6+
7+
`os build` runs the rule table per PACKAGE as well as over the union (`compile.ts` step 3b-ii): each package body is judged as its own stack with the artifact's `packages[]` beside it as resolution context (`packageBodyAsStack`, #16611). On that leg `stack.objects` holds ONE package's objects, so a bundle key naming a sibling's object resolved against nothing. Measured on the probe stack (`examples/app-multi-package`'s shape — `core` owns `crm_account`, `orders` reads it and here also translates it), the key produced one `error` at `translations[0]["zh-CN"].objects.crm_account`:
8+
9+
> 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 — every label it carries renders untranslated.
10+
>
11+
> *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.*
12+
13+
That is the remedy that deletes a translation the runtime resolves, at `error`, so the run FAILED on it — and it is byte-identical, but for the name, to the finding a genuine typo produces. ADR-0130 makes the release artifact the co-ownership boundary, so the miss is the RUN's blind spot and not the author's mistake.
14+
15+
**The precedent is followed, not re-decided.** `validateObjectReferences` closed this exact shape on this exact carrier for object NAMES (#16611 — `artifactProvidedObjectNames` folded into its `resolvable` set). What differs here is the RETURN, and two pins hold it: this rule's universe is keyed by FACTS, not names, so the sibling's fields, options, views, sections and rules are folded WITH the name through the same collector the declaration loop uses. A name-only fold would resolve the object key and then judge the owner's own field keys against an empty fact set — the same false positive one level up — and a wholesale subtree skip (rung 2b's answer, for a target whose declaration is genuinely invisible) would leave the per-package leg unable to see a typo the union leg reports.
16+
17+
**The control, which is what makes this a narrowing and not a hole.** Widening a universe trades a false positive for a blind spot unless every genuine orphan still reports, so both directions are pinned side by side: the same package judged ALONE still errors (the context is what does the work); a name no entry of the artifact declares is still an `error` with its rule id, and the remedy now enumerates what the artifact provides; a field the sibling does not declare is still an `error` under the now-resolved object; one bundle carrying both a sibling key and a typo reports exactly the typo; an entry with no readable body (a segment reference) makes nothing addressable; and the single-`defineStack` shape is untouched, because `objects` is a stack collection with no `stack.manifest` form to read.
18+
19+
No schema moved, no export moved, and no accept set moved: this is a lint rule's false-positive set narrowing. `Clause-②: no`

‎packages/lint/src/validate-translation-references.test.ts‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,6 +1223,199 @@ describe('validateTranslationReferences — an app a package contributes into wi
12231223
});
12241224
});
12251225

1226+
/**
1227+
* ⭐ #19064 — the OBJECT rung of the same rule, on the same per-package leg.
1228+
*
1229+
* `os build` judges each package body as its own stack
1230+
* (`packageBodyAsStack(body, entries)`, `compile.ts` step 3b-ii), where the
1231+
* universe's object collection holds what THIS package declares and nothing
1232+
* else. So a package that translates an object a SIBLING package of the same
1233+
* artifact declares had its object key reported `translation-target-unknown`
1234+
* at `error` — "which no object in this stack defines", remedy "Rename the key
1235+
* to the object it was written for, drop it" — in the same words a genuine
1236+
* typo gets, and the run FAILS on it.
1237+
*
1238+
* ⛔ Not a new resolution-context decision. `validateObjectReferences` took it
1239+
* on this exact carrier for object NAMES (#16611 — `artifactProvidedObjectNames`
1240+
* folded into its `resolvable` set), ADR-0130 makes the release artifact the
1241+
* co-ownership boundary, and this rule's own docblock already declared the same
1242+
* reach for this rung while the rung read `stack.objects` alone.
1243+
*
1244+
* What differs from the precedent is the RETURN, and two cases below are what
1245+
* pin it: this universe is keyed by FACTS, not names, so the sibling's fields,
1246+
* options, views, sections and rules are folded WITH the name. A name-only fold
1247+
* would resolve the object key and then judge the owner's own field keys
1248+
* against an empty fact set — the trap `objectExtensionsByTarget` records one
1249+
* level up — and a wholesale subtree SKIP (rung 2b's answer, for a target whose
1250+
* declaration is genuinely invisible from here) would leave this leg unable to
1251+
* see a typo the union leg reports.
1252+
*/
1253+
describe('validateTranslationReferences — an object a SIBLING package of the artifact declares (#19064)', () => {
1254+
/**
1255+
* `examples/app-multi-package`'s shape — the same corpus
1256+
* `validateObjectReferences`' #16611 block models: `core` owns `crm_account`
1257+
* and `orders` reads it, and here `orders` also TRANSLATES it.
1258+
*/
1259+
const CORE_BODY = {
1260+
id: 'com.example.multi.core',
1261+
objects: [
1262+
{
1263+
name: 'crm_account',
1264+
label: 'Account',
1265+
fields: {
1266+
name: { type: 'text', label: 'Name' },
1267+
industry: { type: 'select', label: 'Industry', options: [{ value: 'tech', label: 'Tech' }] },
1268+
},
1269+
validations: [
1270+
{
1271+
name: 'industry_required',
1272+
type: 'cross_field',
1273+
message: 'An industry is required',
1274+
condition: 'record.industry != null',
1275+
fields: ['industry'],
1276+
},
1277+
],
1278+
},
1279+
],
1280+
};
1281+
const ordersBody = (bundleObjects: Record<string, unknown>) => ({
1282+
id: 'com.example.multi.orders',
1283+
objects: [{ name: 'crm_order', fields: { number: { type: 'text' } } }],
1284+
translations: [{ 'zh-CN': { objects: bundleObjects } }],
1285+
});
1286+
/** `packageBodyAsStack(body, entries)` — the body IS its own manifest. */
1287+
const asPerPackageLeg = (body: Record<string, unknown>, entries: unknown[]) => ({
1288+
...body,
1289+
manifest: body,
1290+
packages: entries,
1291+
});
1292+
const perPackageLeg = (bundleObjects: Record<string, unknown>) => {
1293+
const body = ordersBody(bundleObjects);
1294+
return validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }, { manifest: CORE_BODY }]));
1295+
};
1296+
1297+
/**
1298+
* ⭐ The reproduction, kept as the control: judged with its own entry alone,
1299+
* the very same bundle still errors. Without this leg "no findings" above is
1300+
* indistinguishable from the rung having gone quiet.
1301+
*/
1302+
it('CONTROL — the same package judged ALONE still errors, so the context is what does the work', () => {
1303+
const body = ordersBody({ crm_account: { label: '客户' } });
1304+
const findings = validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }]));
1305+
expect(findings).toHaveLength(1);
1306+
expect(findings[0]).toMatchObject({
1307+
rule: TRANSLATION_TARGET_UNKNOWN,
1308+
severity: 'error',
1309+
path: 'translations[0]["zh-CN"].objects.crm_account',
1310+
});
1311+
});
1312+
1313+
it('accepts the object key when a sibling package of the same artifact declares it', () => {
1314+
expect(perPackageLeg({ crm_account: { label: '客户' } })).toEqual([]);
1315+
});
1316+
1317+
it("judges the subtree against the SIBLING's declaration — its field, option and rule keys resolve", () => {
1318+
expect(
1319+
perPackageLeg({
1320+
crm_account: {
1321+
label: '客户',
1322+
fields: { industry: { label: '行业', options: { tech: '科技' } } },
1323+
_validations: { industry_required: { message: '行业为必填' } },
1324+
},
1325+
}),
1326+
).toEqual([]);
1327+
});
1328+
1329+
/**
1330+
* ⭐ The false-NEGATIVE control. Widening a universe trades a false positive
1331+
* for a blind spot unless every genuine orphan still reports, so a name no
1332+
* entry of the artifact declares stays an `error` with its rule id intact.
1333+
*/
1334+
it('NON-DEGENERACY — a name NO package of the artifact declares still errors', () => {
1335+
const findings = perPackageLeg({ zzz_not_an_object: { label: '没了' } });
1336+
expect(findings).toHaveLength(1);
1337+
expect(findings[0]).toMatchObject({
1338+
rule: TRANSLATION_TARGET_UNKNOWN,
1339+
severity: 'error',
1340+
path: 'translations[0]["zh-CN"].objects.zzz_not_an_object',
1341+
});
1342+
// The remedy enumerates what the ARTIFACT provides, not this package's own
1343+
// objects alone — the same reading `validateObjectReferences` pins for the
1344+
// same widening, and the evidence the fold reached this run at all.
1345+
expect(findings[0].hint).toContain('Defined objects: crm_account, crm_order.');
1346+
});
1347+
1348+
/** ⭐ The second false-negative control, one rung down: the subtree stays judged. */
1349+
it('NON-DEGENERACY — a field the sibling does not declare is still an `error` under the resolved object', () => {
1350+
const findings = perPackageLeg({ crm_account: { fields: { zzz_gone: { label: '没了' } } } });
1351+
expect(findings).toHaveLength(1);
1352+
expect(findings[0]).toMatchObject({
1353+
rule: TRANSLATION_TARGET_UNKNOWN,
1354+
severity: 'error',
1355+
path: 'translations[0]["zh-CN"].objects.crm_account.fields.zzz_gone',
1356+
});
1357+
expect(findings[0].hint).toContain('Declared fields: industry, name.');
1358+
});
1359+
1360+
it('separates the two directions on ONE bundle — the sibling key is silent, the typo is not', () => {
1361+
const findings = perPackageLeg({
1362+
crm_account: { fields: { industry: { label: '行业' }, zzz_gone: { label: '没了' } } },
1363+
zzz_not_an_object: { label: '没了' },
1364+
});
1365+
expect(findings.map((f) => f.path)).toEqual([
1366+
'translations[0]["zh-CN"].objects.crm_account.fields.zzz_gone',
1367+
'translations[0]["zh-CN"].objects.zzz_not_an_object',
1368+
]);
1369+
});
1370+
1371+
/**
1372+
* ⛔ Only the ADR-0130 D4 entry shape is read. A segment reference carries no
1373+
* manifest content, and inventing a name for one would be the one mistake
1374+
* this context must not make — a name in here SILENCES the ladder.
1375+
*/
1376+
it('an entry with no readable body makes nothing addressable', () => {
1377+
const body = ordersBody({ crm_account: { label: '客户' } });
1378+
const findings = validateTranslationReferences(
1379+
asPerPackageLeg(body, [{ manifest: body }, { ref: 'com.example.multi.core@1.0.0', integrity: 'sha512-zzz' }]),
1380+
);
1381+
expect(findings).toHaveLength(1);
1382+
expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.crm_account');
1383+
});
1384+
1385+
/**
1386+
* Which layer's `options` a merged field ends up carrying is the registry's
1387+
* precedence question, and `checkOptionKeys` is the only consumer of the
1388+
* stored definition here — so the declaration this leg is JUDGING keeps the
1389+
* slot, exactly as the `objectExtensions` fold decided one collection over.
1390+
*/
1391+
it("keeps the stack's OWN declaration when a sibling declares the same object name", () => {
1392+
const body = {
1393+
id: 'com.example.multi.orders',
1394+
objects: [{ name: 'crm_account', fields: { industry: { type: 'select', options: [{ value: 'retail' }] } } }],
1395+
translations: [
1396+
{ 'zh-CN': { objects: { crm_account: { fields: { industry: { options: { retail: '零售' } } } } } } },
1397+
],
1398+
};
1399+
expect(
1400+
validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }, { manifest: CORE_BODY }])),
1401+
).toEqual([]);
1402+
});
1403+
1404+
/**
1405+
* The single-`defineStack` shape is untouched: `objects` is a STACK
1406+
* collection, not a manifest key, so there is no `stack.manifest.objects`
1407+
* form to read and a bundle keyed to a name nothing declares still errors.
1408+
*/
1409+
it('leaves the single-stack shape alone — no `packages[]`, no widening', () => {
1410+
const findings = validateTranslationReferences({
1411+
objects: [{ name: 'crm_order', fields: { number: { type: 'text' } } }],
1412+
translations: [{ 'zh-CN': { objects: { crm_account: { label: '客户' } } } }],
1413+
});
1414+
expect(findings).toHaveLength(1);
1415+
expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.crm_account');
1416+
});
1417+
});
1418+
12261419
describe('validateTranslationReferences — flows (#7646 / #11287)', () => {
12271420
/**
12281421
* One stack, shared by every case below — the clean run and the three

0 commit comments

Comments
 (0)