Skip to content

Commit 8293c74

Browse files
refactor(lint): move the i18n translatable-key walk from the CLI into @objectstack/lint (#30375)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBRFy5DNS3Wj4k6EK58wgcaR)_ Part of #30285 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:把「哪些翻译键存在、各自原文是什么」的遍历(`collectExpectedEntries`,2,520 行)原样从 CLI 挪进 `@objectstack/lint`,CLI 改为再导出;不改任何键、骨架或覆盖率结论。 - 为什么改:#30285 要在 Studio 里做「原文 / 译文」对照表,浏览器必须跑同一套遍历;CLI 是 Node 包,浏览器用不了,lint 已被控制台打包使用。 - 风险与回滚:纯搬移。PR 计 +879 / −682(1561 行,低于 3000 行的人工合并线,`check-governed-merges --pr 30375` 读数)。回滚即 revert 本 PR。 - 先看:`packages/lint/src/i18n-walk.ts`(重命名自 `packages/cli/src/utils/i18n-extract.ts`,看头部说明与删掉的尾部)、`packages/cli/src/utils/i18n-bundle-extract.ts`(保留的打包/序列化与再导出)、`packages/cli/test/i18n-bundle-extract-exports.pin.test.ts`(导出不变的钉测)。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run test/i18n-bundle-extract-exports.pin.test.ts test/i18n-walk-output-parity.test.ts`,预期全部通过。 - 需要你做的:无。不是受管面,也未过行数线,席位直接落地。 ## What changes `collectExpectedEntries`, the one definition of which translation keys a stack can carry and the source text each one translates, moves from `packages/cli/src/utils/i18n-extract.ts` to `packages/lint/src/i18n-walk.ts`. It is exported from the `@objectstack/lint` root together with `authorWarnedTranslationGroups`, `humanizeFieldPath` and the `ExpectedEntry` / `ExpectedEntryOptions` types. The point is that a browser can run the same walk the CLI runs. The follow-up PR for #30285 builds Studio's source-to-translation table from it. - **Layout.** The walk keeps git's rename: `i18n-extract.ts → lint/src/i18n-walk.ts`, 17 lines added and 618 removed in place. The removed lines are the extract types and the tail: bundle assembly, `extractTranslations` and serialization. That tail stays in the CLI as a new file, `packages/cli/src/utils/i18n-bundle-extract.ts` (631 lines). It imports the walk from `@objectstack/lint` and re-exports it, so the CLI module offers exactly the names the old one did. - **Importers.** 44 CLI importers (src and test) are repointed from `i18n-extract` to `i18n-bundle-extract`, with no other edit. `normalized-call-sites.test.ts` renames its key to the new path. - **Ledgers and scripts.** The `action` and `view` liveness ledgers cite the walk at its new path (4 cites). `check-i18n-walk-parity.mjs` reads the walk source at its new path and adds it to its watch hints. `dispatch-gates.self-test.mjs` uses the new CLI file as its specimen. Comment-only path repoints are in `i18n-bundle-surface.mjs`, `check-i18n-bundles.mjs` and three lint files. - **Browser default.** In a browser the walk cannot read the liveness ledger from disk (`resolveLivenessDir` answers null), so its default leaves no translation group out. That is the same set the CLI leaves out today, because the ledger warns on no translation group. A lint test pins it, so the day a group is warned, the test fails and a browser caller has to pass `warnedGroups`. - **Changeset.** `@objectstack/lint`, `@objectstack/cli` and `@objectstack/spec` patch (spec for the liveness cites). No key, skeleton or coverage verdict changes. The walk's code moved as is. ## Tests - `packages/lint/src/i18n-walk.test.ts` (new): a stack with an object, a select field with options and an app with navigation yields the expected keys, each with its source text and `source`. A warned group is left out. The shipped ledger warns on no translation group (the browser-parity pin). - `packages/cli/test/i18n-bundle-extract-exports.pin.test.ts` (new): the CLI module's runtime exports equal the old module's 12 names exactly. The walk functions are lint's own (`toBe`), not a copy. The six type exports are still offered (held by type-check). - The CLI's existing i18n suites (about 45 files) reach the walk through the re-export unchanged. ## Acceptance notes - **Size.** `check-governed-merges --pr 30375` puts 0 of 61 paths on the register and reads the size from the PR object, as the queue guard does: +879 / −682 = 1561, under the 3000-line human-merge line. The `--branch` forecast below counts with `--no-renames` (+3382 / −3185 = 6567) and over-reads a rename; it is not the reading the queue guard applies. lint cannot import from cli, because cli depends on lint. - **Stale comment cites left as they are** (comments only, no behaviour): spec `i18n-resolver.ts` (3), `i18n-resolver.object-list-views.test.ts`, platform-objects `source-hash.ts`, two echo-decisions tests, cli `tsconfig.test.json` and `test-typecheck-debt.json`. They name the old path in prose, and editing them would widen the diff into packages this move does not otherwise touch. - **Gate derivation gap (not fixed here).** `check:i18n` and `check:i18n-coverage` derive from `packages/cli/src/**` only, so a later edit to `packages/lint/src/i18n-walk.ts` alone does not derive them locally. CI still runs them. This PR touches `packages/cli/src`, so both were derived and run here. ## Verification <details><summary>lint and cli, under os-verify-lock</summary> ``` lint test :: exit 0 lint typecheck :: exit 0 cli unit :: exit 0 cli typecheck :: exit 0 VERDICT command-exit 0 · held the lock 913s (shared box) ``` </details> <details><summary>dispatch-gates: 87 derived, 87 run, all exit 0 (--ran: 0 NOT-MEASURED, 0 UNRUN)</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:platform-checklist :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:scripts-symbol-anchors :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `check-plugin-teardown-shape.mjs --self-test` first exited 3 because this shallow clone could not read its pinned positive-control commit (621a487). After `git fetch --depth=1 origin 621a487…` it exits 0 (48 cases). That is an environment condition, not this diff. The 87 ran serially under os-verify-lock on a shared box (40m14s). </details> <details><summary>check-governed-merges --branch claude/project-thread-eme43k (head f6ecb06)</summary> ``` governed-surface predicate: 0 of 61 path(s) hit the register size: +3382 / -3185 over 61 file(s) (--no-renames) HUMAN MERGE OR AUTHORIZED APPROVAL - 6567 changed line(s) > 3000 exit 3 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfqCqBH59yrHmuqXH3)_ <!-- signed: domain:ui#1 · os-support-ai · session_01WGufzfE4V9fzZgJLrLDmMC --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1013ef1 commit 8293c74

60 files changed

Lines changed: 879 additions & 682 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/lint': patch
3+
'@objectstack/cli': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
refactor(lint): the i18n translatable-key walk moves from the CLI into `@objectstack/lint`
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
`collectExpectedEntries` — the one definition of which translation keys a stack can carry, each with the source text it translates — now lives in `@objectstack/lint` (`src/i18n-walk.ts`) and is exported from the package root with `authorWarnedTranslationGroups`, `humanizeFieldPath` and the `ExpectedEntry` / `ExpectedEntryOptions` types. It moved so a browser can run the same walk the CLI runs: Studio's translation editor builds its source-to-translation table from it. `os i18n extract` and `os lint`'s translation coverage keep reading it unchanged, through the CLI's extractor module, which keeps bundle assembly and serialization and re-exports the walk.
13+
14+
- No key, no skeleton and no coverage verdict changes: the walk's code moved as is.
15+
- In a browser the walk cannot read the liveness ledger from disk, so its default leaves no translation group out. That is the same set the CLI leaves out today, because the ledger warns on no translation group; a browser caller that must honour a warned group passes `warnedGroups`.
16+
- `@objectstack/spec`: the `action` and `view` liveness ledgers cite the walk at its new path.

‎packages/cli/src/commands/i18n/extract.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
countTranslationLeaves,
3131
type FillStrategy,
3232
type TranslationModuleKind,
33-
} from '../../utils/i18n-extract.js';
33+
} from '../../utils/i18n-bundle-extract.js';
3434
import { authoringRuleUnionStack } from '../../utils/stack-collections.js';
3535

3636
const FILL_STRATEGIES: FillStrategy[] = ['empty', 'default', 'todo'];

‎packages/cli/src/utils/i18n-bundle-extract.ts‎

Lines changed: 631 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/cli/src/utils/i18n-coverage.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
* actually attached to it, and reports the ones that are missing or empty.
88
*
99
* The expected set is **not** computed here. It comes from
10-
* {@link collectExpectedEntries} in `i18n-extract.ts` — the same walker that
11-
* scaffolds bundles for `os i18n extract`. That sharing is the point: this
10+
* {@link collectExpectedEntries} in `@objectstack/lint` (`packages/lint/src/i18n-walk.ts`)
11+
* — the same walker that scaffolds bundles for `os i18n extract`. That sharing is the point: this
1212
* detector used to keep its own parallel walk, and the two drifted until whole
1313
* declared surfaces were extractable but ungated — most visibly the action
1414
* labels declared *inline on an object* (`sys_approval_request`'s
@@ -44,7 +44,7 @@
4444

4545
import { deepMerge } from '@objectstack/core';
4646
import type { TranslationBundle, TranslationData } from '@objectstack/spec/system';
47-
import { collectExpectedEntries, type ExpectedEntry } from './i18n-extract.js';
47+
import { collectExpectedEntries, type ExpectedEntry } from './i18n-bundle-extract.js';
4848

4949
export type CoverageSeverity = 'error' | 'warning';
5050

‎packages/cli/test/i18n-block-inline-title-walk.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// them in English.
1414

1515
import { describe, it, expect } from 'vitest';
16-
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js';
16+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js';
1717
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
1818
import { TranslationDataSchema } from '@objectstack/spec/system';
1919

‎packages/cli/test/i18n-book-walk.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// measure a debt of zero while the sidebar shipped in the authoring language.
99

1010
import { describe, it, expect } from 'vitest';
11-
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js';
11+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js';
1212
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
1313
import { TranslationDataSchema } from '@objectstack/spec/system';
1414

‎packages/cli/test/i18n-bulk-action-coverage.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
// `helpText` here would scaffold keys `.strict()` rejects.
2020

2121
import { describe, it, expect } from 'vitest';
22-
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js';
22+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js';
2323
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
2424
import { ObjectTranslationDataSchema } from '@objectstack/spec/system';
2525

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The translatable-key walk moved to `@objectstack/lint` (`i18n-walk.ts`) so a
5+
* browser can run it; the CLI's extractor module kept everything else and
6+
* re-exports the walk. This pins that the module still offers exactly what
7+
* `utils/i18n-extract.ts` offered before the move, and that the walk names are
8+
* lint's own functions, not a second copy that could drift from them.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import * as lint from '@objectstack/lint';
13+
import * as bundleExtract from '../src/utils/i18n-bundle-extract.js';
14+
import type {
15+
ExpectedEntry,
16+
ExpectedEntryOptions,
17+
ExtractOptions,
18+
ExtractResult,
19+
FillStrategy,
20+
TranslationModuleKind,
21+
} from '../src/utils/i18n-bundle-extract.js';
22+
23+
/** The runtime exports of `packages/cli/src/utils/i18n-extract.ts` before the move. */
24+
const RUNTIME_EXPORTS_BEFORE_THE_MOVE = [
25+
'authorWarnedTranslationGroups',
26+
'collectExpectedEntries',
27+
'countTranslationLeaves',
28+
'extractTranslations',
29+
'humanizeFieldPath',
30+
'narrowToCommittedSections',
31+
'parseSourceHashModule',
32+
'renderSourceHashModule',
33+
'renderTranslationModule',
34+
'stackAuthoredSubtree',
35+
'translationModulePayload',
36+
'translationModuleSections',
37+
];
38+
39+
// The type exports, held by type-check: removing one fails `typecheck`.
40+
type TypeExportsBeforeTheMove = [
41+
ExpectedEntry,
42+
ExpectedEntryOptions,
43+
ExtractOptions,
44+
ExtractResult,
45+
FillStrategy,
46+
TranslationModuleKind,
47+
];
48+
49+
describe('the CLI extractor module after the walk moved to @objectstack/lint', () => {
50+
it('exports exactly the runtime names it exported before', () => {
51+
expect(Object.keys(bundleExtract).sort()).toEqual(RUNTIME_EXPORTS_BEFORE_THE_MOVE);
52+
});
53+
54+
it("re-exports lint's walk functions themselves", () => {
55+
expect(bundleExtract.collectExpectedEntries).toBe(lint.collectExpectedEntries);
56+
expect(bundleExtract.authorWarnedTranslationGroups).toBe(lint.authorWarnedTranslationGroups);
57+
expect(bundleExtract.humanizeFieldPath).toBe(lint.humanizeFieldPath);
58+
});
59+
60+
it('still offers its type exports, with the extract options extending the walk options', () => {
61+
const none: TypeExportsBeforeTheMove | undefined = undefined;
62+
const options: ExtractOptions = { warnedGroups: new Set<string>() };
63+
const walkOptions: ExpectedEntryOptions = options;
64+
expect(none).toBeUndefined();
65+
expect(walkOptions.warnedGroups?.size).toBe(0);
66+
});
67+
});

‎packages/cli/test/i18n-contributed-nav-coverage.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { describe, it, expect } from 'vitest';
2222
import { composeStacks, defineStack } from '@objectstack/spec';
2323
import { TRANSLATION_TARGET_UNKNOWN, validateTranslationReferences } from '@objectstack/lint';
2424

25-
import { collectExpectedEntries } from '../src/utils/i18n-extract.js';
25+
import { collectExpectedEntries } from '../src/utils/i18n-bundle-extract.js';
2626
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
2727
import { authoringRuleUnionStack } from '../src/utils/stack-collections.js';
2828

‎packages/cli/test/i18n-dataset-coverage.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
// otherwise be unaddressable.
2121

2222
import { describe, it, expect } from 'vitest';
23-
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js';
23+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-bundle-extract.js';
2424
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
2525
import { TranslationDataSchema } from '@objectstack/spec/system';
2626

0 commit comments

Comments
 (0)