fix(lint): an orphaned locale key now FAILS the run — translation-target-unknown is an error (#16310) - #17777
Conversation
An orphaned locale key was reported precisely and failed nothing: the rule hard-coded `severity: 'warning'` and `os lint` exits 0 on warnings, so a PR that deletes a navigation entry, form section or view and leaves its locale keys behind was green on every pipeline on the platform. The orphan direction now gates, like the forward `i18n/missing-*` half of the same parity already does. `translation-option-key-unknown` is untouched at `warning`: a mis-keyed option names something real and its remedy is a rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
…arrowing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
…gration The first draft framed the exit-code delta with the `FROM → TO` prescription template, which `check:adr-0087-registration` reads — correctly — as a migration prescription, contradicting the `no-migration-prescription` disposition beside it. 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 been printing each one with its remedy in every release that shipped it. The table now says what it is — a measured before/after of the tool's own verdict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
…phan-locale-key-gateable
📓 Docs Drift Check7 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 23ca7b67ae6f77b59457cd8732c39485fbde933c && git checkout 23ca7b67ae6f77b59457cd8732c39485fbde933c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fce7cd4c46dbe7eacbf8fe108bf8ecc41e76a52c 29bbb8863e2927cfecfb371448c19b1b1573318b && git checkout -B drift-repro fce7cd4c46dbe7eacbf8fe108bf8ecc41e76a52c && git merge --no-ff 29bbb8863e2927cfecfb371448c19b1b1573318b
node scripts/docs-audit/affected-docs.mjs --json fce7cd4c46dbe7eacbf8fe108bf8ecc41e76a52c |
|
PM 复核(本条即本卡的 default-tier 审阅记录,依 tier notice ⭐⭐ 0. 本席的简报漏读了裁决 —— 这是第五次本席的派单写着「 那是错的。裁决早就下了 —— 本席读卡时只读了第一条(分诊)和最后一条(tier notice),裁决躺在中间。⇒ 具体的流程失误是:读卡要读完每一条评论,不是首尾两条。 已写进常备清单。你按裁决执行是对的,⛔ 不是「你自己选了 A」。 1. ⭐⭐ 你对裁决支撑读数的更正,本席逐行核过 —— 成立,而且比你说的更要紧分诊列的三处
⇒ 分诊那份行号清单混了两个规则 id,并且漏掉了被裁决那条规则的第二个站点。⭐ 若照着它的字面执行,后果是两头错:改掉两个 option-key 站点(改错规则,违反验收第 3 条「不得整批升级」),同时把 把严重度声明成一个模块常量、让两个站点不可能漂移,也是对的。 2. 卡面漂移(
|
条款② 席内复核:PASS —— 并说明这张 PR 为什么被踢出队列,以及一处本席无法归属的写入
1. 为什么被踢出来 —— 门禁做对了,错的是本席的入队时机
治理规则(门禁自己引的): ⇒ 本席在载体仍然挂着的时候武装了 auto-merge。 dev 按双载体规矩把 ⭐ 顺带确认门禁那句自述成立:它读的是标签而不是裁决 ——「A carrier stripped before any PASS was on record is indistinguishable here from one that was never hung」。所以下面第 3 节那件事必须写出来。 2. 席内条款② 复核 —— PASS,记录在案依 tier notice 复核记录:评论
⇒ PASS。 依 3.
|
| 载体 | 事件 | 执行者 | 时刻 |
|---|---|---|---|
| PR #17777 | unlabeled |
baozhoutao |
2026-09-12T14:22:55Z |
| 卡 #16310 | unlabeled |
baozhoutao |
2026-09-12T14:22:03Z |
baozhoutao 是本会话的 GitHub 身份,⛔ 但本会话没有做过这两次写入的记录(本席在本卡上的最后一次动作是 04:18 的复核评论,其后经历了一次上下文重置)。⇒ 两种可能:一次丢失的回合,或另一个共用同一身份的会话。本席⛔ 不声称是自己做的,也⛔ 不声称不是 —— 这是能读到的全部。
⭐ 而为什么必须写出来:门禁自己的话 ——「⛔ Stripping the label to get past this check, with no verdict on record, is the defect this leg was built from — not a way through it」。本次剥标没有引任何记录。⇒ 本评论就是把记录补在它后面:裁决确实在案(5643405939,04:18,早于剥标 10 小时),而剥标当时没有引它。 若事后查这条链,这两件事的顺序应当被看见,⛔ 不该被一句「已复核」盖过去。
⇒ 现在两个载体都已不在、PASS 在案且被引用,本席据此 ready + 武装。
4. ⭐ 本席自己的一次假警报,记在这里
写本评论时本席用一段缓存的正则去读两个 Clause-② 载体,得到的值是空串,一度读成「两个声明被抹掉了」。跑了 checker origin/main 上真正的那条正则才发现:它现在有三个捕获组((\?)Clause-②(`?)…:(.*)`),本席读的是第 1 组 —— 一个反引号。
⇒ 两个载体一直都是 Clause-②: yes。⭐ 一次提取不是一次阅读,而缓存下来的正则会在读侧变更后安静地开始答错。本班第 10 次。
Generated by Claude Code
Closes #16310
Clause-②: yes
validate-translation-referencesalready found every orphan locale key, named itsid, 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:
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/lintexports 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:
reaches the JSON report as
rule: "translation-target-unknown", sojq '.issues[] | select(.rule == "translation-target-unknown")'selects ittoday. Pinned in
validate-translation-references.test.tsagainst the literalstring as well as the exported constant, so a rename cannot pass the pin by
moving the constant alone.
default, on the plain
os lint/os validate/os buildexit code, with noflag 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.
passedtruetruetruetruefalsetrue(
--skip-i18n, which suppresses the coverage walk but not this rule, so the tablestays 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:
os lintos validateos build⭐ Negative control — a clean tree is unchanged, no new noise
On the pristine tree the
os lint --jsonreport is identical field for fieldbefore and after, with one exception:
duration(wall clock). Checked on threeclean runs (
baseline,baseline --skip-i18n,restored): sametotal, sameerrors, samewarnings, samepassed, sameissuesarray, 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:
translation-target-unknown,warning→errorsame message and hint text: 121 findings before, 121 after)
translation-option-key-unknown, raised by the same function, deliberately stayswarning: a mis-keyed option translation names something real and its remedy is arename, not a deletion.
validateTranslatableSections— the sibling asking "is therea 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
validateTranslationReferencesreaches the runtime door on aflowwrite (defaultruntimeTypes), 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, andRuntimeStackContexthas notranslationsmember for a host tofill, so the rule sees no bundle and returns nothing there.
Measured: a
flowwrite throughrunRuntimeAuthoringRulesreturns 0 errors and0 advisories from this rule, with
validateReferenceIntegrityconfirmed inrulesRun, andbuildRuntimeWriteSnapshots(...).baselineconfirmed to carry notranslationskey. 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:
validate-translation-references.tsmodule 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.
TranslationRefFinding.severitydoc: "Alwayswarning…" — rewritten to statethe split.
TranslationRefSeverity = 'warning'— widened to'warning' | 'error'.positive" — that false positive now fails the run; the comment says so.
reference-integrity-suite.tsonvalidateTranslatableSections: "warning-onlyfor 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).
severity: 'warning'— re-judged in place, ⛔ notdeleted, 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 mapsf.severitythrough generically;errorpasses through untouched).docs/audits/2026-07-…-reference-integrity-assessment.md: mentions the rule in ahistorical findings table with no severity claim — not falsified.
examples/app-showcase/test/seed.test.ts: already says the rule "fails on a bundleentry no section declares" — not falsified, and now literally true.
reference-integrity-suite.test.ts:352: asserts id membership only — not falsified.examples/app-crm,examples/app-todoand
examples/app-multi-packageeach report 0translation-*findings.(
examples/app-showcasecould not be linted in this container — it fails to load ona missing
@objectstack/connector-mcpdist, a build-ordering condition unrelated tothis diff. Declared to CI.)
One falsified sentence is NOT repaired here, deliberately:
skills/objectstack-i18n/SKILL.md:197says these commands "report it as warnings(
translation-target-unknown,translation-option-key-unknown)" — half of that isnow false.
skills/**is out of this PR's landing scope by dispatch, and it is agoverned 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: injectedspelling
grep -c= 1, replaced spellinggrep -c= 0) turns the rule's test filered — 9 failed / 58 passed, exit 1. Restoring (blob hash equal to
HEAD,git diff HEADempty, mutantgrep -c= 0) returns it to 67 passed, exit 0.The tests import the rule by relative path, so no
dist/is in that loop; thedist/-mediated statement is the CLI table above, measured across a real rebuild.Changeset
@objectstack/lintminor(⛔ noskip-changeset— aClause-②: yesPR takesat least
minor), carrying the**BREAKING**banner with its before/after and theone-line author remedy, plus the ADR-0087 disposition
(
not-required (no-migration-prescription)), verified bypnpm check:adr-0087-registration— exit 0, the disposition echoed on the pass path.FROM → TOprescription template. The gate refused it, correctly: a body carrying amigration prescription contradicts
no-migration-prescription, and none of the otherfour categories is honest here (
unpublished— lint publishes;already-registered—no entry covers this;
runtime-interface-only— inherits the same refusal;type-surface-only— requires anany/unknownbase-side reading, andTranslationRefSeveritywas concrete at base). The changeset now states the delta aswhat 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 passpnpm --filter @objectstack/lint --filter @objectstack/cli typecheck— bothDonescripts/pm/dispatch-gates.mjs --commands, 59 commands):58 exit 0. The one non-zero is
check:dual-build-cjs-loadsexit 3 —PREREQUISITE NOT MET, which prints "⛔ This is NOT a pass: nothing wasmeasured": it reads built output and several unrelated packages have no
dist/in this container. Not measured, declared to CI.
pnpm lintequivalent run in full, not narrowed:eslint . --no-inline-configover 6640 files — 0 errors, 0 warnings, at
29bbb886.Acceptance notes
Claim:comment were placed by the PM; neither was written orchanged here, and no second claim was posted.
Generated by Claude Code