Skip to content

Commit c88fa2c

Browse files
claude[bot]claude
andauthored
fix(lint): an orphaned locale key now FAILS the run — translation-target-unknown is an error (#16310) (#17777)
Closes #16310 Clause-②: yes `validate-translation-references` already found every orphan locale key, named its id, named its locale and printed the remedy — and failed nothing. This makes it fail. Severity only; the rule's detection logic is untouched. ## The ruling this executes Decision batch #91, director seat (comment 5583985173) — **option A**, verbatim: > **Ruled.** An orphaned locale key is dead data that actively misleads — grepping > it returns a confident hit in every locale — and a rule that reports it precisely > while exiting 0 is declared-but-unenforced. The severity goes to `error` at the > three hard-coded sites; `os lint` then fails on it like the forward > `i18n/missing-*` half already does. ⛔ B / D refused … ⛔ C refused (a 195-id > migration across two producers for one rule); ⛔ E refused … The card lists two independent causes and does not order them. **Only cause 1 (severity) is moved.** Cause 2 (the bare, un-namespaced rule id) is option C, which the ruling refused: `packages/lint` exports 200 rule-id constants and 195 are bare, so prefixing this one makes it the sixth exception or forces a cross-producer migration — and renaming a published finding id is itself breaking. ### A consumer really can select this rule by its id (asked for, since only severity moved) Two readings, both on this tree: 1. **Exact-id selection works and is the published contract.** The registry finding reaches the JSON report as `rule: "translation-target-unknown"`, so `jq '.issues[] | select(.rule == "translation-target-unknown")'` selects it today. Pinned in `validate-translation-references.test.ts` against the literal string as well as the exported constant, so a rename cannot pass the pin by moving the constant alone. 2. **After this change no selection is needed at all.** The rule fails the run by default, on the plain `os lint` / `os validate` / `os build` exit code, with no flag and no config. That is the acceptance criterion, and it is met on the default path rather than on an opt-in one. What a consumer still cannot do is reach this rule by *family prefix*. That is exactly the asymmetry the card named, and it stays — by ruling, not by oversight. ## Measured on this tree (⛔ the card is not cited as evidence) `examples/app-crm`, 8 orphan locale keys planted across both locale bundles (`apps.crm_app.navigation`, `objects.crm_lead._sections`, `objects.crm_lead._views`, `objects.crm_lead.fields`), `objectstack lint --json`. Planted, measured, restored; the restore is proven by blob-hash identity against `HEAD`, not by an exit code. The card's shape reproduces: baseline clean, +8 findings when planted, `passed: true`, exit 0 — and the pipeline was green throughout. | tree | total | errors | warnings | `passed` | exit | | :-- | --: | --: | --: | :-- | --: | | **before this PR** | | | | | | | baseline | 12 | 0 | 10 | `true` | 0 | | 8 orphan keys planted | 20 | 0 | 18 | `true` | **0** | | restored | 12 | 0 | 10 | `true` | 0 | | **after this PR** | | | | | | | baseline | 12 | 0 | 10 | `true` | 0 | | 8 orphan keys planted | 20 | **8** | 10 | `false` | **1** | | restored | 12 | 0 | 10 | `true` | 0 | (`--skip-i18n`, which suppresses the coverage walk but not this rule, so the table stays readable. Without it the same run reads 113 / 121 / 113 total with the same 0 → 8 error delta and the same exit-code flip.) All three authoring commands move together on the planted tree: | command | before | after | | :-- | --: | --: | | `os lint` | exit 0 | exit 1 | | `os validate` | exit 0 | exit 1 | | `os build` | exit 0 | exit 1 | ### ⭐ Negative control — a clean tree is unchanged, no new noise On the pristine tree the `os lint --json` report is **identical field for field before and after**, with one exception: `duration` (wall clock). Checked on three clean runs (`baseline`, `baseline --skip-i18n`, `restored`): same `total`, same `errors`, same `warnings`, same `passed`, same `issues` array, same exit code 0. That identity is structural, not lucky: on a clean tree this rule returns zero findings, so the severity literal this PR changes is never reached. ### ⛔ Not "promote all warnings" — 1 rule of 13 Measured on the planted tree, which carries findings from **13 distinct rules**: - rules whose severity set changed: **1** — `translation-target-unknown`, `warning` → `error` - rules unchanged: **12** - the finding set is **identical modulo that one severity** (same count, same paths, same message and hint text: 121 findings before, 121 after) `translation-option-key-unknown`, raised by the *same function*, deliberately stays `warning`: a mis-keyed option translation names something real and its remedy is a rename, not a deletion. `validateTranslatableSections` — the sibling asking "is there a key at all?" — is untouched; its comment claiming it is warning-only *"for the same reason its sibling is"* was corrected, since that reason no longer holds. ### The runtime publish gate is unaffected — a measured zero `validateTranslationReferences` reaches the runtime door on a `flow` write (default `runtimeTypes`), so this could have been a refusal widening at the hottest door. It is not: the per-write snapshot carries only `objects` / `permissions` / `books` / `datasets`, and `RuntimeStackContext` has no `translations` member for a host to fill, so the rule sees no bundle and returns nothing there. Measured: a `flow` write through `runRuntimeAuthoringRules` returns **0 errors and 0 advisories** from this rule, with `validateReferenceIntegrity` confirmed in `rulesRun`, and `buildRuntimeWriteSnapshots(...).baseline` confirmed to carry no `translations` key. No publish that used to succeed is refused. ## ⭐ Reverse-read — which existing sentence does this make false? Six live sentences, all repaired in this PR: 1. `validate-translation-references.ts` module note: *"All findings are **warnings**. An orphan key is inert, not broken."* — rewritten; the inertness reading is what the card measured wrong, and the new note says why, and why ADR-0072 D1 keeps the promotion narrow instead of licensing the neighbours. 2. `TranslationRefFinding.severity` doc: *"Always `warning` …"* — rewritten to state the split. 3. `TranslationRefSeverity = 'warning'` — widened to `'warning' | 'error'`. 4. The nested-screen comment calling a missed screen *"a warning-severity false positive"* — that false positive now fails the run; the comment says so. 5. `reference-integrity-suite.ts` on `validateTranslatableSections`: *"warning-only for the same reason its sibling is"* — the sibling no longer is. Re-grounded on its own reading (the surface is present; only its heading stays in the source locale). 6. Eight test assertions pinning `severity: 'warning'` — **re-judged in place, ⛔ not deleted**, with the reason recorded in a block comment at the head of the file. That silence was deliberate and the note says what it encoded and why it was wrong. **Reported zeros** — swept and found nothing to change: - `content/docs/**`: **0** mentions of this rule id. - `packages/lint/README.md`, `packages/cli/README.md`: **0** mentions. - `packages/cli/src/**`: **0** sentences about this rule's severity (the CLI maps `f.severity` through generically; `error` passes through untouched). - `docs/audits/2026-07-…-reference-integrity-assessment.md`: mentions the rule in a historical findings table with no severity claim — **not** falsified. - `examples/app-showcase/test/seed.test.ts`: already says the rule *"fails on a bundle entry no section declares"* — **not** falsified, and now literally true. - `reference-integrity-suite.test.ts:352`: asserts id membership only — **not** falsified. - In-repo stacks that would newly go red: **0**. `examples/app-crm`, `examples/app-todo` and `examples/app-multi-package` each report **0** `translation-*` findings. (`examples/app-showcase` could not be linted in this container — it fails to load on a missing `@objectstack/connector-mcp` dist, a build-ordering condition unrelated to this diff. Declared to CI.) **One falsified sentence is NOT repaired here, deliberately:** `skills/objectstack-i18n/SKILL.md:197` says these commands *"report it as **warnings** (`translation-target-unknown`, `translation-option-key-unknown`)"* — half of that is now false. `skills/**` is out of this PR's landing scope by dispatch, and it is a governed surface with its own seat. Flagged for routing rather than edited. ## Ablation — the new pins can fail Reverting the severity constant to `'warning'` in source (on-disk proof: injected spelling `grep -c` = 1, replaced spelling `grep -c` = 0) turns the rule's test file **red — 9 failed / 58 passed, exit 1**. Restoring (blob hash equal to `HEAD`, `git diff HEAD` empty, mutant `grep -c` = 0) returns it to **67 passed, exit 0**. The tests import the rule by relative path, so no `dist/` is in that loop; the `dist/`-mediated statement is the CLI table above, measured across a real rebuild. ## Changeset `@objectstack/lint` **`minor`** (⛔ no `skip-changeset` — a `Clause-②: yes` PR takes at least `minor`), carrying the `**BREAKING**` banner with its before/after and the one-line author remedy, plus the ADR-0087 disposition (`not-required (no-migration-prescription)`), verified by `pnpm check:adr-0087-registration` — exit 0, the disposition echoed on the pass path. ⚠️ **Worth a reviewer's eye.** The first draft framed the exit-code delta with the `FROM → TO` prescription template. The gate refused it, correctly: a body carrying a migration prescription contradicts `no-migration-prescription`, and none of the other four categories is honest here (`unpublished` — lint publishes; `already-registered` — no entry covers this; `runtime-interface-only` — inherits the same refusal; `type-surface-only` — requires an `any`/`unknown` base-side reading, and `TranslationRefSeverity` was concrete at base). The changeset now states the delta as what it is — a measured before/after of the tool's own verdict — because it is not a migration: no authorable key moves, an orphan key resolved to nothing before this release and resolves to nothing after it, and the rule has printed each one with its remedy in every release that shipped it. ⛔ The `**BREAKING**` token was not dropped. If a reviewer reads that as a category gap rather than a mislabel on my part, it is the #13080 shape one axis over (a published *verdict* narrowing) and wants its own card. ## Verification - `pnpm --filter @objectstack/lint test` — **101 files, 3749 tests, all pass** - `pnpm --filter @objectstack/lint --filter @objectstack/cli typecheck` — both `Done` - Derived gate family (`scripts/pm/dispatch-gates.mjs --commands`, 59 commands): **58 exit 0**. The one non-zero is `check:dual-build-cjs-loads` **exit 3 — `PREREQUISITE NOT MET`**, which prints *"⛔ This is NOT a pass: nothing was measured"*: it reads built output and several unrelated packages have no `dist/` in this container. Not measured, declared to CI. - `pnpm lint` equivalent run in full, not narrowed: `eslint . --no-inline-config` over **6640 files — 0 errors, 0 warnings**, at `29bbb886`. ## Acceptance notes - ⛔ Auto-merge not armed; PR is a draft. - Assignee and the `Claim:` comment were placed by the PM; neither was written or changed here, and no second claim was posted. --- _Generated by [Claude Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f62536 commit c88fa2c

4 files changed

Lines changed: 218 additions & 21 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: an orphaned locale key now FAILS the run — `translation-target-unknown` is an `error` (#16310)
6+
7+
`validate-translation-references` reported every orphan translation key precisely
8+
— the id named, the locale named, the remedy printed — and failed nothing.
9+
`os lint` exits 0 on warnings, the rule hard-coded `severity: 'warning'`, and no
10+
per-rule severity is configurable by a consuming app. So a PR that deletes a
11+
navigation entry, a form section or a view and leaves its locale keys behind was
12+
green on every pipeline on the platform, and the dead keys are actively
13+
misleading afterwards: grepping the id returns a confident-looking hit in every
14+
locale, which reads as "this exists and is translated".
15+
16+
The forward half of this parity — `i18n/missing-*`, an authored surface with no
17+
translation — already fails, and apps already gate on it. The orphan half now
18+
fails too, so the two halves of one parity have the same enforceability instead
19+
of opposite ones.
20+
21+
**BREAKING** — a stack carrying an orphan locale key stops passing `os lint`,
22+
`os validate` and `os build`. Measured on one stack with 8 orphan keys planted,
23+
`objectstack lint --json`:
24+
25+
| `@objectstack/lint` | findings | errors | warnings | `passed` | exit |
26+
| :-- | --: | --: | --: | :-- | --: |
27+
| before this release | 20 | 0 | 18 | `true` | 0 |
28+
| after this release | 20 | 8 | 10 | `false` | 1 |
29+
30+
The findings themselves are unchanged — same count, same paths, same message and
31+
hint text. Only the severity moves, and with it the exit code.
32+
33+
**What an author does about it.** In a clean stack, nothing: a tree with no
34+
orphan key reports exactly what it reported before, at the same severities, with
35+
the same exit code (measured — the report is identical field for field apart
36+
from its wall-clock `duration`). In a stack the rule already names findings on,
37+
delete each locale key it names. The key resolves to nothing — the object,
38+
field, view, section, tab, action, param, app, nav item, dashboard, widget or
39+
flow screen it was written for is not in the stack — so removing it changes no
40+
rendered string in any locale. Where the target was renamed rather than removed,
41+
key the translation to the new name instead; the finding prints the declared
42+
names to choose from.
43+
44+
**This is ONE rule, not "warnings are errors now".** Measured on a planted tree
45+
carrying findings from 13 distinct rules: exactly 1 changed severity, 12 did not,
46+
and the finding set is identical modulo that one severity.
47+
`translation-option-key-unknown` — raised by the same function — stays `warning`
48+
on purpose: a mis-keyed option translation names something real and its remedy is
49+
a rename, not a deletion. `validateTranslatableSections`, the sibling asking "is
50+
there a key at all?", is untouched.
51+
52+
**Unchanged: the runtime publish gate.** `validateTranslationReferences` reaches
53+
the runtime door on a `flow` write, but the per-write snapshot carries only
54+
`objects` / `permissions` / `books` / `datasets``RuntimeStackContext` has no
55+
`translations` member for a host to fill — so the rule sees no bundle and returns
56+
nothing there. Measured: a flow write through `runRuntimeAuthoringRules` yields
57+
0 errors and 0 advisories from this rule. No publish that used to succeed is
58+
refused.
59+
60+
`TranslationRefSeverity` widens from `'warning'` to `'warning' | 'error'`
61+
accordingly.
62+
63+
<!-- adr-0087: not-required (no-migration-prescription) nothing an author writes moves: no authorable key is renamed, retired or reshaped, `packages/spec/**` is untouched, no Zod schema and no stored metadata shape changes, and the diff moves one lint severity literal plus the exported TS union that types it. `objectstack migrate meta` has nothing to reach and the ledger serves nobody affected. The author action this release can imply is not an upgrade step either: an orphan key resolved to nothing before this release and resolves to nothing after it, and the rule has been naming each one, with its remedy, in every release that shipped it — what changes is that the report is no longer ignorable, not what the metadata is supposed to say. -->

packages/lint/src/reference-integrity-suite.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,8 +387,11 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
387387
// section authored with a `label` and no `name` renders a heading that
388388
// `_sections` (keyed by name) can never address, so neither the orphan check
389389
// nor the coverage walk can see it. A reference that cannot be written is
390-
// still a reference question, and warning-only for the same reason its
391-
// sibling is: one heading stays in the source locale, nothing breaks.
390+
// still a reference question, and warning-only on its own reading — NOT on
391+
// its sibling's any more: `translation-target-unknown` gates, because an
392+
// orphan key is a confident-looking grep hit for a surface that no longer
393+
// exists. Here the surface is real and present; only its heading stays in the
394+
// source locale, so nothing is misdescribed and nothing breaks.
392395
{ name: 'validateTranslatableSections', run: validateTranslatableSections },
393396
{ name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths },
394397
{ name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity },

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

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,33 @@ import { ContactViews } from '../../../examples/app-showcase/src/ui/views/contac
1818
// a section that has no name.
1919
import { SnapshotContact, SnapshotContactViews } from './showcase-shape.fixtures.js';
2020

21+
/**
22+
* ⭐ Severity, re-judged in place (#16310) — ⛔ not deleted.
23+
*
24+
* Eight assertions in this file pinned `severity: 'warning'` on
25+
* `translation-target-unknown`, and that silence was deliberate: the rule's own
26+
* Severity note argued an orphan key is inert — a few bytes and one untranslated
27+
* string, nothing crashes — so gating on it would be the over-statement
28+
* ADR-0072 D1 forbids.
29+
*
30+
* The reading was measured wrong in the one direction that matters. An orphan
31+
* key is not inert; it is a confident-looking grep hit, in every locale, for a
32+
* surface that was deleted — which reads as "this exists and is translated" to
33+
* the next author, human or AI. Reported-but-unfailable meant a PR that deletes
34+
* a navigation entry, a form section or a view and leaves its locale keys behind
35+
* was green on every pipeline on the platform (measured: eight planted orphans
36+
* moved `os lint --json` from 12/10 to 20/18 findings, `passed: true`, exit 0).
37+
*
38+
* So every one of those eight now pins `error`. They are the SAME assertions
39+
* making the same statement one severity later, and they stay because the
40+
* severity is exactly what is worth pinning here.
41+
*
42+
* ⚠️ The neighbours are untouched on purpose: `translation-option-key-unknown`
43+
* still pins `warning` (see the "option keys" and "severity is narrow" blocks) —
44+
* a mis-keyed option names something real and its remedy is a rename, not a
45+
* deletion.
46+
*/
47+
2148
/** A stack shaped like the HotCRM lead surface: fields, options, a view, an action. */
2249
const leadStack = (translations: unknown[]) => ({
2350
objects: [
@@ -67,7 +94,7 @@ describe('validateTranslationReferences — orphan keys', () => {
6794
]),
6895
);
6996
expect(findings).toHaveLength(1);
70-
expect(findings[0].severity).toBe('warning');
97+
expect(findings[0].severity).toBe('error');
7198
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
7299
expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.crm_lead.fields.assigned_to');
73100
expect(findings[0].hint).toContain('Declared fields: name, source, status.');
@@ -128,7 +155,7 @@ describe('validateTranslationReferences — orphan keys', () => {
128155
'translations[0].en.objects.crm_lead._sections.deal_info',
129156
'translations[0].en.objects.crm_lead._actions.mass_update',
130157
]);
131-
expect(findings.every((f) => f.severity === 'warning')).toBe(true);
158+
expect(findings.every((f) => f.severity === 'error')).toBe(true);
132159
});
133160

134161
it('flags an action parameter the action does not declare', () => {
@@ -266,6 +293,71 @@ describe('validateTranslationReferences — nested conditional validation branch
266293
});
267294
});
268295

296+
describe('validateTranslationReferences — the severity split is narrow (#16310)', () => {
297+
/**
298+
* The gating claim, pinned from the consumer's side rather than from the
299+
* rule's: a consumer selects this rule by its EXACT id, because the id is the
300+
* bare string the registry publishes — no namespace, and none added here (the
301+
* id shape was ruled out of scope: a namespace for this one rule would make it
302+
* the sixth prefixed id among 200 exported rule-id constants, or a migration
303+
* across two producers). So the two things a gate needs are the id string and
304+
* the severity, and both are asserted here together.
305+
*/
306+
it('raises `translation-target-unknown` at `error`, selectable by its exact id', () => {
307+
const findings = validateTranslationReferences(
308+
leadStack([
309+
{ 'zh-CN': { objects: { crm_lead: { fields: { assigned_to: { label: '负责人' } } } } } },
310+
]),
311+
);
312+
const selected = findings.filter((f) => f.rule === 'translation-target-unknown');
313+
expect(selected).toHaveLength(1);
314+
expect(selected[0].severity).toBe('error');
315+
// The id is the literal a consumer's filter can be written against — the
316+
// constant and the wire string are the same value, asserted both ways so a
317+
// rename cannot pass this test by moving the constant alone.
318+
expect(TRANSLATION_TARGET_UNKNOWN).toBe('translation-target-unknown');
319+
});
320+
321+
/**
322+
* ⛔ The promotion is ONE rule's, not "every warning becomes an error". The
323+
* sibling raised by the very same function keeps `warning`, so a tree whose
324+
* only translation defect is a mis-keyed option is unchanged — same finding,
325+
* same severity, same exit code as before.
326+
*/
327+
it('leaves `translation-option-key-unknown` at `warning`', () => {
328+
const findings = validateTranslationReferences(
329+
leadStack([
330+
{
331+
'zh-CN': {
332+
objects: {
333+
crm_lead: { fields: { source: { label: '来源', options: { 'direct-mail': '直邮' } } } },
334+
},
335+
},
336+
},
337+
]),
338+
);
339+
expect(findings).toHaveLength(1);
340+
expect(findings[0].rule).toBe(TRANSLATION_OPTION_KEY_UNKNOWN);
341+
expect(findings[0].severity).toBe('warning');
342+
});
343+
344+
/** The negative control: a clean bundle still reports nothing, of any severity. */
345+
it('reports nothing on a bundle whose every key resolves', () => {
346+
const findings = validateTranslationReferences(
347+
leadStack([
348+
{
349+
'zh-CN': {
350+
objects: {
351+
crm_lead: { label: '线索', fields: { name: { label: '名称' } } },
352+
},
353+
},
354+
},
355+
]),
356+
);
357+
expect(findings).toEqual([]);
358+
});
359+
});
360+
269361
describe('validateTranslationReferences — option keys', () => {
270362
it('flags an option key that is a near-miss of the stored value', () => {
271363
// The HotCRM instance: `direct-mail` for the value `direct_mail`.
@@ -398,7 +490,7 @@ describe('validateTranslationReferences — cross-package objects (§4 ladder)',
398490
translations: bundleFor('sys_approval_process'),
399491
});
400492
expect(findings).toHaveLength(1);
401-
expect(findings[0].severity).toBe('warning');
493+
expect(findings[0].severity).toBe('error');
402494
expect(findings[0].message).toContain('platform namespace');
403495
// The object key is reported once; its subtree is not half-checked.
404496
expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.sys_approval_process');
@@ -585,7 +677,7 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => {
585677
bundle({ lead_conversions: { label: 'x', screens: { details: { title: 'y' } } } }),
586678
);
587679
expect(findings).toHaveLength(1);
588-
expect(findings[0].severity).toBe('warning');
680+
expect(findings[0].severity).toBe('error');
589681
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
590682
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.lead_conversions');
591683
expect(findings[0].message).toContain('Did you mean "lead_conversion"?');
@@ -598,7 +690,7 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => {
598690
bundle({ lead_conversion: { label: '线索转换', screens: { detail: { title: 'y' } } } }),
599691
);
600692
expect(findings).toHaveLength(1);
601-
expect(findings[0].severity).toBe('warning');
693+
expect(findings[0].severity).toBe('error');
602694
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.lead_conversion.screens.detail');
603695
expect(findings[0].message).toContain('Did you mean "details"?');
604696
expect(findings[0].hint).toContain('ScreenSpec.nodeId');
@@ -614,7 +706,7 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => {
614706
}),
615707
);
616708
expect(findings).toHaveLength(1);
617-
expect(findings[0].severity).toBe('warning');
709+
expect(findings[0].severity).toBe('error');
618710
expect(findings[0].path).toBe(
619711
'translations[0]["zh-CN"].flows.lead_conversion.screens.details.fields.opportunity',
620712
);
@@ -703,7 +795,7 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => {
703795
],
704796
});
705797
expect(findings).toHaveLength(1);
706-
expect(findings[0].severity).toBe('warning');
798+
expect(findings[0].severity).toBe('error');
707799
expect(findings[0].message).toContain('OBJECT-FORM screen');
708800
expect(findings[0].hint).toContain('objects.crm_lead.fields.owner');
709801
});
@@ -1295,7 +1387,7 @@ describe('validateTranslationReferences — filter-preset tabs (#13835)', () =>
12951387
]),
12961388
);
12971389
expect(findings).toHaveLength(1);
1298-
expect(findings[0].severity).toBe('warning');
1390+
expect(findings[0].severity).toBe('error');
12991391
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
13001392
expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.crm_lead._tabs.overdue');
13011393
expect(findings[0].hint).toContain('Declared tabs: mine, urgent.');

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

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,29 @@
2828
*
2929
* ── Severity ─────────────────────────────────────────────────────────────
3030
*
31-
* All findings are **warnings**. An orphan key is inert, not broken: it costs a
32-
* few bytes and one untranslated string, and nothing crashes. That is a weaker
33-
* failure than the dead references `validate-object-references` /
34-
* `validate-action-name-refs` report as errors, and the severity should say so
35-
* (ADR-0072 D1 — a linter that over-states is a linter authors stop reading).
31+
* `translation-target-unknown` is an **error**; `translation-option-key-unknown`
32+
* stays a **warning**. The split is the difference between a key that points at
33+
* nothing and a key that points at the wrong spelling of something.
34+
*
35+
* The orphan direction used to be a warning on the reading that an orphan key is
36+
* inert — a few bytes and one untranslated string, nothing crashes. That reading
37+
* was measured wrong in the direction that matters: the key is not inert, it is
38+
* **actively misleading**. Grepping its id returns a confident-looking hit in
39+
* every locale, which reads as "this surface exists and is translated" — to a
40+
* human reviewer and to an AI author alike — long after the navigation entry,
41+
* form section or view it was written for was deleted.
42+
*
43+
* Reported-but-unfailable is the shape that let that happen: the rule named
44+
* every orphan, printed the remedy, and exited 0, so a PR that deletes a surface
45+
* and leaves its locale keys behind was green on every pipeline on the platform.
46+
* The forward half of the same parity (`i18n/missing-*` — an authored surface
47+
* with no translation) already fails; the two halves of one parity now have the
48+
* same enforceability instead of opposite ones.
49+
*
50+
* ⚠️ ADR-0072 D1 (a linter that over-states is a linter authors stop reading) is
51+
* what keeps this narrow. It is not a licence to promote the neighbours: a
52+
* mis-keyed OPTION translation resolves to a declared option's near-miss and its
53+
* remedy is a rename, not a deletion, so `checkOptionKeys` keeps `warning`.
3654
*
3755
* ── What this rule deliberately does NOT check ───────────────────────────
3856
*
@@ -121,10 +139,24 @@ const SCREEN_NODE_TYPE = 'screen';
121139
export const TRANSLATION_TARGET_UNKNOWN = 'translation-target-unknown';
122140
export const TRANSLATION_OPTION_KEY_UNKNOWN = 'translation-option-key-unknown';
123141

124-
export type TranslationRefSeverity = 'warning';
142+
export type TranslationRefSeverity = 'warning' | 'error';
143+
144+
/**
145+
* The severity every `translation-target-unknown` finding carries.
146+
*
147+
* Declared once so the two sites that raise the rule cannot drift apart, and so
148+
* the rule's gating claim is readable as a value rather than inferred from two
149+
* scattered string literals.
150+
*/
151+
const TRANSLATION_TARGET_UNKNOWN_SEVERITY = 'error' as const;
125152

126153
export interface TranslationRefFinding {
127-
/** Always `warning` — an orphan translation key is inert, not broken. */
154+
/**
155+
* `error` for `translation-target-unknown` (an orphan key resolves to nothing
156+
* and gates the run), `warning` for `translation-option-key-unknown` (a
157+
* mis-keyed option resolves to nothing but names something real). See the
158+
* module's Severity note.
159+
*/
128160
severity: TranslationRefSeverity;
129161
/** Diagnostic rule id. */
130162
rule: string;
@@ -726,8 +758,8 @@ function buildUniverse(stack: AnyRec): Universe {
726758
// real screen the runner pauses on and hands the client a `ScreenSpec.nodeId`
727759
// for, so its translation key resolves. Reading the flat array would leave
728760
// every nested screen out of the universe and report each of its keys as an
729-
// orphan — a warning-severity false positive, which is exactly the
730-
// over-stating ADR-0072 D1 forbids.
761+
// orphan — a false positive that now FAILS the run, which is exactly the
762+
// over-stating ADR-0072 D1 forbids, at the cost the gating severity sets.
731763
const flows = new Map<string, FlowFacts>();
732764
for (const flow of recordsOf(stack.flows)) {
733765
const flowName = strName(flow.name);
@@ -777,7 +809,14 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
777809
const universe = buildUniverse(stack);
778810

779811
const orphan = (where: string, path: string, message: string, hint: string) => {
780-
findings.push({ severity: 'warning', rule: TRANSLATION_TARGET_UNKNOWN, where, path, message, hint });
812+
findings.push({
813+
severity: TRANSLATION_TARGET_UNKNOWN_SEVERITY,
814+
rule: TRANSLATION_TARGET_UNKNOWN,
815+
where,
816+
path,
817+
message,
818+
hint,
819+
});
781820
};
782821

783822
for (let bi = 0; bi < bundles.length; bi++) {
@@ -1223,7 +1262,7 @@ function checkActionParams(
12231262
for (const paramName of rawParams) {
12241263
if (declared.has(paramName)) continue;
12251264
findings.push({
1226-
severity: 'warning',
1265+
severity: TRANSLATION_TARGET_UNKNOWN_SEVERITY,
12271266
rule: TRANSLATION_TARGET_UNKNOWN,
12281267
where: `${ctx.where} · param "${paramName}"`,
12291268
path: `${ctx.path}.params.${paramName}`,

0 commit comments

Comments
 (0)