fix(cli): i18n 覆盖走查收集 objects.*._sections —— 段落标题不再是「声明了、渲染了、却零告警」的面 (#5405) - #5416
Conversation
`ExpectedEntry['source']` had no `section` kind, so
`objects.<o>._sections.<s>.label` was structurally unreachable: `os i18n
extract` never scaffolded a heading and `os lint` could not report one
missing — while `ObjectTranslationDataSchema` declares `_sections` and
`@object-ui/i18n`'s `sectionLabel` resolves it for `record:details`,
`ObjectForm`/`ModalForm` and the field-group designer.
Adds a `section` kind fed by both authoring surfaces, deduped to one key
per (object, section):
(a) `fieldGroups` × field `group`, read through the shared ADR-0085
`deriveFieldGroupLayout` so a group nothing visible references —
or a `group:` no `fieldGroups` entry declares — yields no key;
(b) a NAMED `sections[]` on a form view (including a container's
default `form`) or inside a record page's component tree.
`COVERAGE_SOURCE` maps it, giving `os lint` an `i18n/missing-section`
category in the user bucket (not folded away with `--include-platform`).
Page sections go through `@objectstack/lint`'s shared page traversal,
now exported, instead of a private copy — that walk exists because
duplicating it produced a dead rule once (#3583), and it is what makes
this half correct rather than merely present: `slots.<slot>`, the
untyped `page:tabs` -> `properties.items[].children[]` nesting, skipping
source-authored pages whose regions are a derived cache, and per
component binding resolution so a re-bound `record:details` keys its
headings under the object it actually shows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 22 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
objects.<o>._sections —— 段落标题不再是「声明了、渲染了、却零告警」的面 (#5405)objects.*._sections —— 段落标题不再是「声明了、渲染了、却零告警」的面 (#5405)
`check:i18n-coverage`(仓库级棘轮门)在 CI 抓到 showcase 未译声明串
451 → 461:新增的 `section` kind 把段落标题纳入覆盖面后,showcase
声明支持 zh-CN 却没有这些译文。门要的是译文,不是豁免 —— 基线与
`supportedLocales` 均未改动。
zh-CN 新增 10 条,均为既有体例:
showcase_task._sections —— task.view.ts 三个表单投影的段落名
overview 概览 / schedule 排期 / details 详细信息
step_basics 基本信息 / step_assign 指派 / step_schedule 排期
split_task 任务 / split_schedule 排期
showcase_semantic_zoo._sections —— ADR-0085 fixture 的两个 fieldGroups
basics 基本信息 / money 财务信息
后者按 `showcase_inquiry` / `showcase_business_unit` 的先例,只加
`_sections` 块(该对象本无 zh-CN 块,其余债务留在冻结基线内)。
`money` 取「财务信息」以贴合分组自己的 description(Financial
fields),而非直译 Money。
`en` 不加条目:默认语言由内联 label 满足,复述源串只会伪造覆盖率。
check-i18n-coverage: OK (12 config(s), 660 baselined untranslated
string(s), none new) —— showcase 精确回到 451,零 missing-section。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh
Rework:
|
| key | 源串 | zh-CN |
|---|---|---|
showcase_task._sections.overview.label |
Overview | 概览 |
showcase_task._sections.schedule.label |
Schedule | 排期 |
showcase_task._sections.details.label |
Details | 详细信息 |
showcase_task._sections.step_basics.label |
Basics | 基本信息 |
showcase_task._sections.step_assign.label |
Assignment | 指派 |
showcase_task._sections.step_schedule.label |
Schedule | 排期 |
showcase_task._sections.split_task.label |
Task | 任务 |
showcase_task._sections.split_schedule.label |
Schedule | 排期 |
showcase_semantic_zoo._sections.basics.label |
Basics | 基本信息 |
showcase_semantic_zoo._sections.money.label |
Money | 财务信息 |
用词对齐 bundle 既有体例(任务 / 负责人 / 进度 / 状态),不为同一概念引入第二个词;money 取「财务信息」以贴合分组自己的 description(Financial fields)而非直译 Money。showcase_semantic_zoo 按 showcase_inquiry / showcase_business_unit 的先例只加 _sections 块 —— 该对象本无 zh-CN 块,其余债务留在冻结基线内。
验证:
node scripts/check-i18n-coverage.mjs
check-i18n-coverage: OK (12 config(s), 660 baselined untranslated string(s), none new).
os lint examples/app-showcase/objectstack.config.ts --json
showcase i18n issues now: 451 (baseline 451) ← 精确回到基线,非低于
missing-section remaining: 0
pnpm check:i18n
check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2
Test Files 78 passed (78) Tests 750 passed (750)
pnpm --filter @objectstack/example-showcase test
Test Files 12 passed (12) Tests 124 passed (124)
typecheck (cli / lint / app-showcase) → 三个都 Done
精确回到 451 而非更低是有意的:该门是双向棘轮,计数下降同样报错(要求 --update 落基线),所以「多译几条」并不是更安全的选择。
教训(记进报告):上一轮只跑了包级 pnpm --filter ... test/typecheck,而这道门是仓库级(pnpm check:i18n-coverage,且需要先 turbo run build 出 packages/cli/bin/run.js 才能跑)。凡是新增翻译面的改动,包级验证按定义覆盖不到消费面的仓库级棘轮 —— 下次此类改动应在推送前直接跑一次 check:i18n-coverage。
顺带:构建产物核对时发现 ContactViews 从未注册进 objectstack.config.ts(产物里只有 4 个视图),所以它默认 form 的四个具名段落在真实 config 上对覆盖门完全不可见 —— 已有平行 agent 立单 #5420,按查重纪律未开重复单,只在那边补了独立复现与这条下游影响。
Generated by Claude Code
…ck-ai#5420) (objectstack-ai#5438) `src/ui/views/contact.view.ts` declares the whole "create form != edit form" reference implementation — a default grouped `form` with four named sections, a sparse `formViews.create`, and the list's `addRecord: { mode: 'form', formView: 'create' }` binding — and `src/ui/views/index.ts` exports it. `objectstack.config.ts` named the other four containers on line 23 and line 196 and never named this one. There is no directory scan behind `views:`; the CLI reads exactly that array. So the metadata compiled, type-checked and linted clean while reaching nothing: `nav_contacts` rendered a derived default form instead of the authored one, `addRecord.formView` never bound, and no static pass (`os validate` / `os lint` / `os i18n extract` / the coverage ratchet) could see it either — which is also why objectstack-ai#5405's new `i18n/missing-section` gate found 10 headings in the showcase and zero of them under `showcase_contact`. `content/docs/ui/create-vs-edit-form.mdx` cites this file as the live reference implementation the whole time. Registration makes exactly five zh-CN coverage keys newly reachable, measured on the real config rather than predicted: os lint examples/app-showcase/objectstack.config.ts 483 warnings (before) -> 488 (registered, untranslated) -> 484 (translated) + i18n/missing-view objects.showcase_contact._views.list.label + i18n/missing-section objects.showcase_contact._sections.{contact,work,status,notes}.label All five are translated in this commit, following PR objectstack-ai#5416's rework: zh-CN takes its words from the vocabulary this bundle already uses, and `en` gains nothing (the default locale is satisfied by the inline labels; echoing the source string only fakes coverage). The ratchet baseline `scripts/i18n-coverage-baseline.json` and `i18n.supportedLocales` are untouched — `check-i18n-coverage: OK (12 config(s), 660 baselined untranslated string(s), none new)`, showcase back to exactly 451. Two guards in `test/seed.test.ts`: * every container the barrel exports reaches `stack.views`, matched by target object (`defineStack` parses the config, so a registered container is a structural copy and never `===` the export). Reverting the config hunk fails it naming `ContactViews`. * the four section names and their zh-CN `_sections` keys are asserted as set EQUALITY, because the two i18n gates read that set in opposite directions — `i18n/missing-section` fails on a declared section with no entry, `translation-target-unknown` on an entry no section declares. With objectstack-ai#5422 on main this is the first time the relationship is testable on the real config: the four correctly-translated sections are NOT reported as orphans. The remaining `translation-target-unknown` on `_views.list` is objectstack-ai#5164's open contradiction between the three `_views` producers, not new here — the showcase already carried four identical instances (project / task / inquiry / business_unit) before this change. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
…e heading no key can address (objectstack-ai#5417) (objectstack-ai#5455) `_sections` is keyed by the section's `name`, and every renderer resolves a section heading that way (`sectionLabel(objectName, section.name, authored)`), falling back to the authored label when there is no name. So a section authored with a `label` and no `name` is untranslatable by construction — and both existing gates are structurally blind to it: there is no orphan key for `validateTranslationReferences` to report, and the coverage walker (objectstack-ai#5405) emits one expected key per `sections[].name`, so a nameless section demands nothing and the report reads 100%. Measured: 70/70 HotCRM form-view sections are in that state at full declared coverage; `os validate` over examples/app-showcase reports 14 (6 form views, 8 record:details pages) and still exits 0. `validateTranslatableSections` (`translation-section-name-missing`) joins the reference-integrity suite, so it runs on validate/lint/compile at once, and reads exactly the anchors objectstack-ai#5416's walker and objectstack-ai#5422's fact set already agree on — a container's `sections`, its DEFAULT `form.sections`, every `listViews.*`/`formViews.*` sub-container, the same three on object-embedded views, and `record:details` sections anywhere in a page's component tree via the shared `walkPageComponents`. fieldGroups-derived sections are out of range by construction (their key IS the name), as is a name-keyed `sections` map. Warning, and opt-in: a section warns only when the object it renders under carries some translation of its own, so a monolingual stack stays silent exactly as `computeI18nCoverage` does. The fix is a diagnostic at the producer — slugifying the label into a lookup key would fossilize a second de-facto contract next to the declared one (Prime Directive objectstack-ai#12, and the renderer guard objectui#3373 pinned as correct). Per the PM ruling on the issue, requiring `name` on `FormSection` outright (the schema route) is a breaking authoring change left to the maintainer; packages/spec is untouched. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
Fixes #5405
前提核验(先证伪,再动手)
按 rule 6 对
origin/main(b4872a868)逐条验证 issue 正文,三条全部成立:grep -c "_sections" packages/cli/src/utils/i18n-extract.ts→0;反查对照组_views→9、fieldGroups→0。零命中不是 grep 失灵。ExpectedEntry['source']联合类型(i18n-extract.ts:96-111)确无section;两个metadataForm*项走的是metadataForms.{type}.sections.*这条 Studio 元数据表单命名空间,与 app object 无关。COVERAGE_SOURCE(i18n-coverage.ts)照抄同一联合,computeI18nCoverage结构上不可能报出缺失的段落标题。反向面同样核实过:声明面
ObjectTranslationDataSchema._sections(packages/spec/src/system/translation.zod.ts:216,并把sections列为 authoring alias);消费面@object-ui/i18n的sectionLabel(useObjectLabel.ts:397-400)被plugin-detail的record:details、plugin-form的ObjectForm/ModalForm、以及ObjectFormDesigner三处读取。只有走查器不知道这个面存在。改法
新增
sectionkind,双源取齐,同一 (object, section) 只产一个 key:(a)
fieldGroups× 字段group。 经deriveFieldGroupLayout(ADR-0085 §5)判定 —— 这正是渲染端自己消费的那份共享推导(plugin-detail的deriveFieldGroupDetailSections把它的key直接映射成sectionLabel(object, name, …)查的那个name)。因此:没有可见字段引用的已声明分组不出 key,fieldGroups未声明的group:也不出 key,尾部无 key 的 ungrouped 桶不出 key —— 屏幕上不存在的标题,不该记一笔翻译债。fieldGroups[].label供默认语言文本;分组自身没写label时仍产出脚手架 key(种子退回分组名),但inline留空,覆盖门不去追一句没人写过的话。(b) 具名
sections[]。 表单视图(含容器的默认form)与记录页组件树。无name的段落一律跳过 —— 每个渲染端都把查表挂在它上面(s?.name ? sectionLabel(…) : s?.label),没有name就是构造性不可翻译。COVERAGE_SOURCE映射之,os lint得到i18n/missing-section类别,落在用户桶而非平台桶(不需要--include-platform才看得见);os i18n extract的骨架经共享走查器免费获得。页面这半边:复用,而不是再抄一份
页面段落走
@objectstack/lint的walkPageComponents(本 PR 顺带把它连同isSourceAuthoredPage/WalkedComponent从 lint 的 index 导出)。page-walk.ts的模块头就写着它为何独此一份:重复实现已经造出过一条死规则(#3583)。复用不只是 DRY,它是这半边正确与仅仅存在的分界:regions[].components和slots.{slot}(后者可以是裸组件而非数组);properties袋里(page:tabs→properties.items[].children[],page:card→properties.body/.footer)—— 也就是 issue 点名的那层深嵌套;kind: 'html' | 'react' | 'jsx'的页面,regions只是source的派生缓存,拿它刮 key 等于凭空发明一个作者面;dataSource.object→properties.object→ 页面object)。这条比「漏」更要紧:被dataSource改绑到别的对象的record:details,若按页面对象记 key,产出的是一个解析器永远不读的错路径。验证
新增
packages/cli/test/i18n-section-coverage.test.ts(22 例)。反向核验 —— 方向是先判后跑,结果是常规的「红」: 这是补一个从未存在的面,不是收窄既有规则,所以预判为「注释掉
walkObjectSections(config, out)调用 → 新用例转红」。实测16 failed | 4 passed,方向吻合。那 4 例正是断言「不该产出」的负向用例 —— 它们在删除态下因为什么都没产出而绿,即 PR #5046 记录的空集假绿。据此逐条整改,而不是留给下一个读者踩:name的段落跳过」由同 describe 首例的精确toEqual([三个 key])兜住,负向断言只是把意图写明;对账锚(声明即强制),两面各一处:
extractTranslations产出的 bundle 直接喂给ObjectTranslationDataSchema.safeParse,要求全绿且_sections键集合精确匹配。走查器若发明了拼写(sections/_section/.title),在这里被严格 schema 当场拒绝,而不是安静地刮出一堆 bundle 合法性存疑的 key。deriveFieldGroupLayout自己返回的有 key 段落集合。渲染端消费的就是这份推导,于是「声明的」与「被门禁的」是同一个集合,而不是两张会漂移的清单。真实取数 —— 直接 import 仓内示例 app 的两个模块,不造假数据:
examples/app-showcase/src/ui/views/contact.view.ts(容器默认form的四个具名段落contact/work/status/notes,绑showcase_contact)与examples/app-showcase/src/data/objects/semantic-zoo.object.ts(fieldGroups: basics/money,两者都被真实字段的group:引用),断言六个 key 一个不多一个不少。按车道纪律该用例显式}, 60_000)。越界与顺带发现
_views族的任何拼写/键逻辑(_viewstranslation keys have three producers that disagree on the spelling — a default-onlylistcontainer can never resolve #5164 领地),未触碰commands/doctor.ts(在飞os doctor的 installed-package ledger 读取 catch 把「损坏」和「没装」当成同一件事 —— D5e 建议因此静默少报,并打出「clean」 #5412)。packages/lint/src/index.ts增加一行导出。理由如上 —— 另一个选择是在 cli 里再抄一份页面走查,而那正是page-walk.ts存在的目的所反对的,且会丢掉按组件解析绑定这条正确性。packages/lint无 API 基线门禁,全量 1198 例通过。translation-target-unknownmisses a view container's DEFAULTform.sections— correctly translating a rendered heading is reported as a stale key #5415(未在本 PR 修):packages/lint的validate-translation-references在收集_sections事实时,collectViewRecord只遍历['listViews', 'formViews'],漏掉视图容器的默认form.sections。实测:给showcase_contact正确翻译一个真实渲染的段落标题,会被报translation-target-unknown,措辞还是「该对象根本没有具名段落」—— 建议作者删掉一个能用的翻译。本 PR 落地后二者会在真实 showcase 元数据上互相打架(extract 刮出的 key 立刻被 lint 判为未知目标),所以现在就记了下来。应有的集合关系是:覆盖走查器要求的每个 key,都必须是引用校验器接受的 key。