Skip to content

Commit aff9e56

Browse files
os-zhuangclaude
andauthored
fix(i18n): translate the platform packages' declared surface; gate all nine bundles, not one (#3762) (#3780)
Closes the fillable half of #3762. Follows #3370#3753#3768. Nine packages own a translation bundle; ONE was wired into a drift check. The other eight shipped a `scripts/i18n-extract.config.ts` that nothing ever ran, and four had already drifted — plugin-security / plugin-audit / plugin-sharing / plugin-webhooks, 4 locales each out of date. Exactly the silent staleness `pnpm check:i18n` exists to prevent, sitting one directory outside the only path it guarded. Translated: plugin-security (45 strings/locale), plugin-webhooks (15), plugin-audit (8), plugin-sharing (7), service-storage (7) — 246 translations, zero untranslated declared strings across zh-CN / ja-JP / es-ES. Most were newly VISIBLE rather than newly missing: #3753 taught the coverage detector to walk action `params`, `resultDialog`, `listViews`, object `description`, field `help`/`placeholder`. Wording was harvested from the repo's own bundles first (1382 unambiguous en→locale mappings scraped from what is already translated), so `Created At` is `创建时间` here because that is what it is everywhere else; only 59 strings had no precedent and were written by hand. Protocol tokens are deliberately identical across locales: GET/POST/PUT/PATCH/DELETE, ETag, ACL, URL. `scripts/check-i18n-bundles.mjs` replaces the single-package `pnpm check:i18n` and covers all nine (40 bundles). It does not restate each package's command — it parses the regenerate command out of that config's own docstring and runs exactly that, plus `--check`, so the documented command and the gate cannot diverge. Same anti-drift principle as making the coverage detector share the extractor's walker in #3753: one source, not two that agree today. The #3768 coverage ratchet grows the same way, `examples/*` → twelve configs: app-showcase / app-todo / app-crm at 456 / 212 / 97, platform-objects at 231, and the other eight packages at 0 — meaning the ratchet IS the strict gate for them, any regression immediately red rather than merely visible. Both gates were verified to bite, not merely run: deleting a key block the schema still declares reports DRIFTED and exits 1, restoring exits 0 (a hand-edited translation correctly does NOT trip it — merge mode preserves it, and the gate is about schema drift); the ratchet was proven in #3768 (97 → 99, exit 1). A real truncation bug this exposed: wiring the ratchet to platform-objects failed on unparseable JSON whose output was exactly 65536 bytes — one pipe buffer. `console.log(big)` followed by `process.exit(1)` tears the process down before an async pipe write drains; interactively it looks perfect because stdout to a TTY is synchronous, but piped — i.e. every scripted consumer — it silently yields invalid JSON. `emitJson` (packages/cli/src/utils/format.ts) awaits the write callback and sets `process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it, and `os lint --json` on platform-objects goes from 65536 truncated bytes to 234603 that parse. ~30 other CLI commands share the pattern and are NOT touched here — a mechanical sweep that could not be verified command-by-command in this change. The ratchet also captures to a file rather than a pipe regardless, since a gate that can read a truncated payload and report a smaller number is worse than no gate. The nine documented regenerate commands gain `--no-metadata-forms` (#3768) — the Studio metadata-form baseline belongs to platform-objects alone, not a copy in every plugin. Not in scope: platform-objects' own 77-per-locale gap is `apps.*` / `dashboards.*` navigation and widget labels, which live outside the `objects` subtree, so the package's `--objects-only` extract cannot scaffold them — which is why `--check` reports in sync while coverage reports 77 missing. That needs an emit decision (drop `--objects-only`, or a companion `.apps.generated.ts`) before any translating; it stays in #3762 and the 231 is frozen in the baseline meanwhile. Also filed: #3778. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7687f7b commit aff9e56

38 files changed

Lines changed: 1067 additions & 110 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/plugin-audit": patch
4+
"@objectstack/plugin-sharing": patch
5+
"@objectstack/plugin-webhooks": patch
6+
"@objectstack/service-storage": patch
7+
"@objectstack/cli": patch
8+
---
9+
10+
fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762)
11+
12+
Only `platform-objects` was wired into a translation-drift check. The other
13+
**eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever
14+
ran — and four of them had already drifted out of sync with the schema, exactly
15+
the rot `pnpm check:i18n` exists to catch, one directory over.
16+
17+
**Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks`
18+
(15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now
19+
at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246
20+
translations. Most were newly *visible* rather than newly missing: #3753 taught
21+
the coverage detector to walk action `params`, `resultDialog`, `listViews` and
22+
the rest of the declared surface, and these are what it found.
23+
24+
Wording was harvested from the repo's own bundles wherever a string was already
25+
translated somewhere (1382 unambiguous source strings), so `Created At` reads
26+
`创建时间` here because that is what it reads everywhere else, rather than a
27+
fresh invention. Protocol tokens are deliberately left identical across locales:
28+
`GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`.
29+
30+
**Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package
31+
`pnpm check:i18n` and checks all nine. It does not restate each package's
32+
command — it parses the one already documented in that config's own docstring
33+
and runs it, so the documented regenerate command and the gate cannot diverge.
34+
The coverage ratchet grows the same way, from `examples/*` to twelve configs;
35+
eight of them sit at zero, which makes it the strict gate there.
36+
37+
**Fixed a real truncation bug it exposed.** `os lint --json` on a large config
38+
came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed
39+
by `process.exit(1)` tears the process down before an async pipe write drains,
40+
while an interactive run (stdout is a TTY, written synchronously) looks perfect.
41+
Every scripted consumer silently got invalid JSON. `emitJson` in
42+
`packages/cli/src/utils/format.ts` waits for the write to drain and sets
43+
`process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it.
44+
Roughly 30 other CLI commands share the pattern and are not touched here.
45+
46+
The nine documented regenerate commands also gain `--no-metadata-forms` (added
47+
in #3768), since the Studio metadata-form baseline belongs to `platform-objects`
48+
alone, not to a copy in every plugin.
49+
50+
Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` /
51+
`dashboards.*` navigation and widget labels, which live outside the `objects`
52+
subtree and cannot be scaffolded while the package extracts with
53+
`--objects-only`. That needs an emit decision first — tracked in #3762.

.github/workflows/lint.yml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,21 +262,30 @@ jobs:
262262
# mode, so it never asks anyone to re-translate: a fresh extract of an
263263
# up-to-date bundle is byte-identical to what is committed.
264264
#
265-
# Reads the built @objectstack/spec dist through the extract config, so
265+
# Now covers ALL NINE packages that own a bundle, not just
266+
# platform-objects. The other eight shipped an `i18n-extract.config.ts`
267+
# that nothing ever ran, and four of them had already drifted — the same
268+
# rot this gate exists to catch, one directory over. Each package is
269+
# checked with the exact command its own config docstring documents, so
270+
# the docs and the gate cannot diverge.
271+
#
272+
# Reads the built @objectstack/spec dist through the extract configs, so
266273
# it belongs after the build step with the other consumer gates.
267274
- name: Check generated translation bundles are in sync with the schema
268275
run: pnpm check:i18n
269276

270277
# Ratchet on the OTHER i18n question. The step above asks "are the
271278
# generated bundles still what the schema produces?"; this one asks "did
272279
# anyone declare a new label and not translate it?" — the gap #3370 closed
273-
# in `os lint` but which had ~765 pre-existing misses across the examples,
274-
# so `--i18n-strict` cannot simply be switched on without painting CI red
275-
# and getting switched back off. The debt is frozen in
280+
# in `os lint`, which had ~1000 pre-existing misses across the examples and
281+
# platform-objects, so `--i18n-strict` cannot simply be switched on without
282+
# painting CI red and getting switched back off. The debt is frozen in
276283
# scripts/i18n-coverage-baseline.json; growth fails the build.
277284
#
278-
# Runs the built CLI over each example config, so it also belongs after
279-
# the build step.
285+
# Eight of the twelve tracked configs sit at zero, so for those this is
286+
# already the strict gate — any regression is immediately red.
287+
#
288+
# Runs the built CLI over each config, so it also belongs after the build.
280289
- name: Check no new untranslated declared labels
281290
run: pnpm check:i18n-coverage
282291

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d
188188
| Path | Type | Rule |
189189
|:---|:---|:---|
190190
| `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. |
191-
| `packages/platform-objects/src/apps/translations/*.generated.ts` | **AUTO-GEN** | ❌ Never hand-edit. Run `pnpm i18n:extract` (merge mode — existing translations are preserved). `pnpm check:i18n` gates it in CI. |
191+
| `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode — every existing translation is preserved); `pnpm i18n:extract` still covers `platform-objects` alone. Translation *values* are hand-written and expected to be: the gate compares against a merge-mode extract, so editing a string is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. |
192192
| `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. |
193193
| `content/docs/concepts/` | hand-written ||
194194
| `content/docs/getting-started/` | hand-written ||

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"objectui:clean": "rm -rf packages/console/dist .cache/objectui-*",
2828
"lint": "eslint . --no-inline-config",
2929
"i18n:extract": "tsx packages/cli/bin/run-dev.js i18n extract packages/platform-objects/scripts/i18n-extract.config.ts --locales=zh-CN,ja-JP,es-ES --fill=default --out=packages/platform-objects/src/apps/translations",
30-
"check:i18n": "pnpm i18n:extract --check",
30+
"check:i18n": "node scripts/check-i18n-bundles.mjs",
3131
"check:i18n-coverage": "node scripts/check-i18n-coverage.mjs",
3232
"check:nul-bytes": "node scripts/check-nul-bytes.mjs",
3333
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",

packages/cli/src/commands/i18n/check.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
printInfo,
1313
printStep,
1414
createTimer,
15+
emitJson,
1516
} from '../../utils/format.js';
1617
import { computeI18nCoverage } from '../../utils/i18n-coverage.js';
1718

@@ -77,12 +78,10 @@ export default class I18nCheck extends Command {
7778
: [];
7879

7980
if (flags.json) {
80-
console.log(JSON.stringify({
81-
...report,
82-
thresholdViolations,
83-
duration: timer.elapsed(),
84-
}, null, 2));
85-
if (report.totals.errors > 0 || thresholdViolations.length > 0) process.exit(1);
81+
await emitJson(
82+
{ ...report, thresholdViolations, duration: timer.elapsed() },
83+
report.totals.errors > 0 || thresholdViolations.length > 0 ? 1 : 0,
84+
);
8685
return;
8786
}
8887

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
printInfo,
1414
printStep,
1515
createTimer,
16+
emitJson,
1617
} from '../../utils/format.js';
1718
import { extractTranslations, renderTranslationModule, type FillStrategy } from '../../utils/i18n-extract.js';
1819

@@ -159,15 +160,15 @@ export default class I18nExtract extends Command {
159160
flags['metadata-forms'] && (metadataFormsCounts[locale] ?? 0) > 0;
160161

161162
if (flags.json) {
162-
console.log(JSON.stringify({
163+
await emitJson({
163164
totalExpected: result.totalExpected,
164165
counts: result.counts,
165166
metadataFormsCounts,
166167
bundles: objectsOnly
167168
? Object.fromEntries(localesEmitted.map((l) => [l, result.bundles[l].objects ?? {}]))
168169
: result.bundles,
169170
duration: timer.elapsed(),
170-
}, null, 2));
171+
});
171172
return;
172173
}
173174

packages/cli/src/commands/lint.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
printInfo,
2626
printStep,
2727
createTimer,
28+
emitJson,
2829
} from '../utils/format.js';
2930

3031
// ─── Types ──────────────────────────────────────────────────────────
@@ -672,7 +673,7 @@ export default class Lint extends Command {
672673
const errors = issues.filter((i) => i.severity === 'error');
673674
const warnings = issues.filter((i) => i.severity === 'warning');
674675
const suggestions = issues.filter((i) => i.severity === 'suggestion');
675-
console.log(JSON.stringify({
676+
await emitJson({
676677
passed: errors.length === 0,
677678
total: issues.length,
678679
errors: errors.length,
@@ -682,8 +683,7 @@ export default class Lint extends Command {
682683
...(score ? { score: score.score, grade: score.grade } : {}),
683684
issues,
684685
duration: timer.elapsed(),
685-
}, null, 2));
686-
if (errors.length > 0) process.exit(1);
686+
}, errors.length > 0 ? 1 : 0);
687687
return;
688688
}
689689

packages/cli/src/utils/format.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ import type { ZodError } from 'zod';
77
export const CLI_NAME = 'objectstack';
88
export const CLI_ALIAS = 'os';
99

10+
// ─── Machine-readable output ────────────────────────────────────────
11+
12+
/**
13+
* Emit a `--json` payload and record the exit code, without truncating.
14+
*
15+
* `console.log(big)` followed by `process.exit(1)` looks correct and is not:
16+
* when stdout is a **pipe**, Node buffers the write asynchronously and
17+
* `process.exit` tears the process down with the buffer only partly drained.
18+
* `os lint packages/platform-objects/scripts/i18n-extract.config.ts --json`
19+
* came out of a pipe at exactly 65536 bytes — one pipe buffer — so every
20+
* scripted consumer got invalid JSON while an interactive run (stdout is a TTY,
21+
* written synchronously) looked perfect. Silent, and invisible to the author.
22+
*
23+
* Waiting for the write callback drains the buffer first, and setting
24+
* `process.exitCode` instead of calling `process.exit` lets Node exit on its
25+
* own once nothing is pending.
26+
*/
27+
export async function emitJson(payload: unknown, exitCode = 0): Promise<void> {
28+
const text = JSON.stringify(payload, null, 2) + '\n';
29+
await new Promise<void>((resolve, reject) => {
30+
process.stdout.write(text, (err) => (err ? reject(err) : resolve()));
31+
});
32+
if (exitCode !== 0) process.exitCode = exitCode;
33+
}
34+
1035
// ─── Banner ─────────────────────────────────────────────────────────
1136

1237
export function printBanner(version: string) {

packages/plugins/plugin-audit/scripts/i18n-extract.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* strings were seeded from @objectstack/platform-objects.)
99
*
1010
* os i18n extract packages/plugins/plugin-audit/scripts/i18n-extract.config.ts \
11-
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \
11+
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
1212
* --out=packages/plugins/plugin-audit/src/translations
1313
*/
1414

packages/plugins/plugin-audit/src/translations/en.objects.generated.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
3737
label: "Actor",
3838
help: "User who performed the action (null for system actions)"
3939
},
40+
actor: {
41+
label: "Actor",
42+
help: "Principal that performed the action: a user id, svc:<name>, or null"
43+
},
4044
object_name: {
4145
label: "Object",
4246
help: "Target object (e.g. sys_user, project_task)"
@@ -73,7 +77,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
7377
},
7478
_views: {
7579
recent: {
76-
label: "Recent"
80+
label: "Recent",
81+
emptyState: {
82+
title: "No audit events",
83+
message: "Activity will appear here as users interact with the platform."
84+
}
7785
},
7886
writes_only: {
7987
label: "Writes"
@@ -140,6 +148,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
140148
label: "Record Label",
141149
help: "Display label of the target record at write time"
142150
},
151+
source_object: {
152+
label: "Source Object",
153+
help: "Object name of the rich source entity this activity was derived from (e.g. \"sys_email\"). Null when the activity is about the target record itself."
154+
},
155+
source_id: {
156+
label: "Source ID",
157+
help: "Record id of the rich source entity (paired with source_object) — lets the timeline drill to the full email/call/meeting record."
158+
},
143159
url: {
144160
label: "URL",
145161
help: "Optional deep-link to the activity target"

0 commit comments

Comments
 (0)