diff --git a/.changeset/i18n-translate-platform-bundles.md b/.changeset/i18n-translate-platform-bundles.md new file mode 100644 index 0000000000..c2a0e93a34 --- /dev/null +++ b/.changeset/i18n-translate-platform-bundles.md @@ -0,0 +1,53 @@ +--- +"@objectstack/plugin-security": patch +"@objectstack/plugin-audit": patch +"@objectstack/plugin-sharing": patch +"@objectstack/plugin-webhooks": patch +"@objectstack/service-storage": patch +"@objectstack/cli": patch +--- + +fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762) + +Only `platform-objects` was wired into a translation-drift check. The other +**eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever +ran — and four of them had already drifted out of sync with the schema, exactly +the rot `pnpm check:i18n` exists to catch, one directory over. + +**Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks` +(15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now +at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246 +translations. Most were newly *visible* rather than newly missing: #3753 taught +the coverage detector to walk action `params`, `resultDialog`, `listViews` and +the rest of the declared surface, and these are what it found. + +Wording was harvested from the repo's own bundles wherever a string was already +translated somewhere (1382 unambiguous source strings), so `Created At` reads +`创建时间` here because that is what it reads everywhere else, rather than a +fresh invention. Protocol tokens are deliberately left identical across locales: +`GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`. + +**Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package +`pnpm check:i18n` and checks all nine. It does not restate each package's +command — it parses the one already documented in that config's own docstring +and runs it, so the documented regenerate command and the gate cannot diverge. +The coverage ratchet grows the same way, from `examples/*` to twelve configs; +eight of them sit at zero, which makes it the strict gate there. + +**Fixed a real truncation bug it exposed.** `os lint --json` on a large config +came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed +by `process.exit(1)` tears the process down before an async pipe write drains, +while an interactive run (stdout is a TTY, written synchronously) looks perfect. +Every scripted consumer silently got invalid JSON. `emitJson` in +`packages/cli/src/utils/format.ts` waits for the write to drain and sets +`process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it. +Roughly 30 other CLI commands share the pattern and are not touched here. + +The nine documented regenerate commands also gain `--no-metadata-forms` (added +in #3768), since the Studio metadata-form baseline belongs to `platform-objects` +alone, not to a copy in every plugin. + +Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` / +`dashboards.*` navigation and widget labels, which live outside the `objects` +subtree and cannot be scaffolded while the package extracts with +`--objects-only`. That needs an emit decision first — tracked in #3762. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2dd169ce1a..d9198b33ad 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -262,7 +262,14 @@ jobs: # mode, so it never asks anyone to re-translate: a fresh extract of an # up-to-date bundle is byte-identical to what is committed. # - # Reads the built @objectstack/spec dist through the extract config, so + # Now covers ALL NINE packages that own a bundle, not just + # platform-objects. The other eight shipped an `i18n-extract.config.ts` + # that nothing ever ran, and four of them had already drifted — the same + # rot this gate exists to catch, one directory over. Each package is + # checked with the exact command its own config docstring documents, so + # the docs and the gate cannot diverge. + # + # Reads the built @objectstack/spec dist through the extract configs, so # it belongs after the build step with the other consumer gates. - name: Check generated translation bundles are in sync with the schema run: pnpm check:i18n @@ -270,13 +277,15 @@ jobs: # Ratchet on the OTHER i18n question. The step above asks "are the # generated bundles still what the schema produces?"; this one asks "did # anyone declare a new label and not translate it?" — the gap #3370 closed - # in `os lint` but which had ~765 pre-existing misses across the examples, - # so `--i18n-strict` cannot simply be switched on without painting CI red - # and getting switched back off. The debt is frozen in + # in `os lint`, which had ~1000 pre-existing misses across the examples and + # platform-objects, so `--i18n-strict` cannot simply be switched on without + # painting CI red and getting switched back off. The debt is frozen in # scripts/i18n-coverage-baseline.json; growth fails the build. # - # Runs the built CLI over each example config, so it also belongs after - # the build step. + # Eight of the twelve tracked configs sit at zero, so for those this is + # already the strict gate — any regression is immediately red. + # + # Runs the built CLI over each config, so it also belongs after the build. - name: Check no new untranslated declared labels run: pnpm check:i18n-coverage diff --git a/AGENTS.md b/AGENTS.md index 376dea61b6..e30b8a682c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d | Path | Type | Rule | |:---|:---|:---| | `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. | -| `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. | +| `**/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. | | `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. | | `content/docs/concepts/` | hand-written | ✅ | | `content/docs/getting-started/` | hand-written | ✅ | diff --git a/package.json b/package.json index 6247963c5c..aab194ccf1 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "objectui:clean": "rm -rf packages/console/dist .cache/objectui-*", "lint": "eslint . --no-inline-config", "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", - "check:i18n": "pnpm i18n:extract --check", + "check:i18n": "node scripts/check-i18n-bundles.mjs", "check:i18n-coverage": "node scripts/check-i18n-coverage.mjs", "check:nul-bytes": "node scripts/check-nul-bytes.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs", diff --git a/packages/cli/src/commands/i18n/check.ts b/packages/cli/src/commands/i18n/check.ts index 0bf7a6fd99..63b8720ec5 100644 --- a/packages/cli/src/commands/i18n/check.ts +++ b/packages/cli/src/commands/i18n/check.ts @@ -12,6 +12,7 @@ import { printInfo, printStep, createTimer, + emitJson, } from '../../utils/format.js'; import { computeI18nCoverage } from '../../utils/i18n-coverage.js'; @@ -77,12 +78,10 @@ export default class I18nCheck extends Command { : []; if (flags.json) { - console.log(JSON.stringify({ - ...report, - thresholdViolations, - duration: timer.elapsed(), - }, null, 2)); - if (report.totals.errors > 0 || thresholdViolations.length > 0) process.exit(1); + await emitJson( + { ...report, thresholdViolations, duration: timer.elapsed() }, + report.totals.errors > 0 || thresholdViolations.length > 0 ? 1 : 0, + ); return; } diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index d7e492fe2d..32fcbcfe35 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -13,6 +13,7 @@ import { printInfo, printStep, createTimer, + emitJson, } from '../../utils/format.js'; import { extractTranslations, renderTranslationModule, type FillStrategy } from '../../utils/i18n-extract.js'; @@ -159,7 +160,7 @@ export default class I18nExtract extends Command { flags['metadata-forms'] && (metadataFormsCounts[locale] ?? 0) > 0; if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ totalExpected: result.totalExpected, counts: result.counts, metadataFormsCounts, @@ -167,7 +168,7 @@ export default class I18nExtract extends Command { ? Object.fromEntries(localesEmitted.map((l) => [l, result.bundles[l].objects ?? {}])) : result.bundles, duration: timer.elapsed(), - }, null, 2)); + }); return; } diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index e6b0fbbd7e..e19968c2f7 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -25,6 +25,7 @@ import { printInfo, printStep, createTimer, + emitJson, } from '../utils/format.js'; // ─── Types ────────────────────────────────────────────────────────── @@ -672,7 +673,7 @@ export default class Lint extends Command { const errors = issues.filter((i) => i.severity === 'error'); const warnings = issues.filter((i) => i.severity === 'warning'); const suggestions = issues.filter((i) => i.severity === 'suggestion'); - console.log(JSON.stringify({ + await emitJson({ passed: errors.length === 0, total: issues.length, errors: errors.length, @@ -682,8 +683,7 @@ export default class Lint extends Command { ...(score ? { score: score.score, grade: score.grade } : {}), issues, duration: timer.elapsed(), - }, null, 2)); - if (errors.length > 0) process.exit(1); + }, errors.length > 0 ? 1 : 0); return; } diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index bdd1b190bc..7799e174ee 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -7,6 +7,31 @@ import type { ZodError } from 'zod'; export const CLI_NAME = 'objectstack'; export const CLI_ALIAS = 'os'; +// ─── Machine-readable output ──────────────────────────────────────── + +/** + * Emit a `--json` payload and record the exit code, without truncating. + * + * `console.log(big)` followed by `process.exit(1)` looks correct and is not: + * when stdout is a **pipe**, Node buffers the write asynchronously and + * `process.exit` tears the process down with the buffer only partly drained. + * `os lint packages/platform-objects/scripts/i18n-extract.config.ts --json` + * came out of a pipe at exactly 65536 bytes — one pipe buffer — so every + * scripted consumer got invalid JSON while an interactive run (stdout is a TTY, + * written synchronously) looked perfect. Silent, and invisible to the author. + * + * Waiting for the write callback drains the buffer first, and setting + * `process.exitCode` instead of calling `process.exit` lets Node exit on its + * own once nothing is pending. + */ +export async function emitJson(payload: unknown, exitCode = 0): Promise { + const text = JSON.stringify(payload, null, 2) + '\n'; + await new Promise((resolve, reject) => { + process.stdout.write(text, (err) => (err ? reject(err) : resolve())); + }); + if (exitCode !== 0) process.exitCode = exitCode; +} + // ─── Banner ───────────────────────────────────────────────────────── export function printBanner(version: string) { diff --git a/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts b/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts index dd3186edf8..997f19fec7 100644 --- a/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-audit/scripts/i18n-extract.config.ts @@ -8,7 +8,7 @@ * strings were seeded from @objectstack/platform-objects.) * * os i18n extract packages/plugins/plugin-audit/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ * --out=packages/plugins/plugin-audit/src/translations */ diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index b98ccc62a1..5476129bc6 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -37,6 +37,10 @@ export const enObjects: NonNullable = { label: "Actor", help: "User who performed the action (null for system actions)" }, + actor: { + label: "Actor", + help: "Principal that performed the action: a user id, svc:, or null" + }, object_name: { label: "Object", help: "Target object (e.g. sys_user, project_task)" @@ -73,7 +77,11 @@ export const enObjects: NonNullable = { }, _views: { recent: { - label: "Recent" + label: "Recent", + emptyState: { + title: "No audit events", + message: "Activity will appear here as users interact with the platform." + } }, writes_only: { label: "Writes" @@ -140,6 +148,14 @@ export const enObjects: NonNullable = { label: "Record Label", help: "Display label of the target record at write time" }, + source_object: { + label: "Source Object", + 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." + }, + source_id: { + label: "Source ID", + help: "Record id of the rich source entity (paired with source_object) — lets the timeline drill to the full email/call/meeting record." + }, url: { label: "URL", help: "Optional deep-link to the activity target" diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index a7f0e0d5b3..665986bffe 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -37,6 +37,10 @@ export const esESObjects: NonNullable = { label: "Actor", help: "Usuario que realizó la acción (null para acciones del sistema)." }, + actor: { + label: "Actor", + help: "Principal que realizó la acción: un id de usuario, svc: o null" + }, object_name: { label: "Objeto", help: "Objeto de destino (p. ej. sys_user, project_task)." @@ -73,7 +77,11 @@ export const esESObjects: NonNullable = { }, _views: { recent: { - label: "Recientes" + label: "Recientes", + emptyState: { + title: "Sin eventos de auditoría", + message: "La actividad aparecerá aquí a medida que los usuarios interactúen con la plataforma." + } }, writes_only: { label: "Escrituras" @@ -140,6 +148,14 @@ export const esESObjects: NonNullable = { label: "Nombre visible del registro", help: "Nombre visible del registro de destino en el momento de escritura." }, + source_object: { + label: "Objeto de origen", + help: "Nombre de objeto de la entidad de origen enriquecida de la que deriva esta actividad (p. ej. \"sys_email\"). Null cuando la actividad trata sobre el propio registro de destino." + }, + source_id: { + label: "ID de origen", + help: "Id de registro de la entidad de origen enriquecida (emparejado con source_object): permite que la línea de tiempo profundice hasta el registro completo de correo/llamada/reunión." + }, url: { label: "URL", help: "Enlace profundo opcional al destino de la actividad." diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index 42d5ac7c40..80d66b1e98 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -37,6 +37,10 @@ export const jaJPObjects: NonNullable = { label: "操作者", help: "アクションを実行したユーザー(システム操作の場合は null)" }, + actor: { + label: "実行者", + help: "操作を実行したプリンシパル: ユーザー ID、svc:、または null" + }, object_name: { label: "オブジェクト", help: "対象オブジェクト(例: sys_user、project_task)" @@ -73,7 +77,11 @@ export const jaJPObjects: NonNullable = { }, _views: { recent: { - label: "最近" + label: "最近", + emptyState: { + title: "監査イベントはありません", + message: "ユーザーがプラットフォームを操作すると、ここにアクティビティが表示されます。" + } }, writes_only: { label: "書き込み" @@ -140,6 +148,14 @@ export const jaJPObjects: NonNullable = { label: "レコード表示名", help: "書き込み時点の対象レコードの表示名" }, + source_object: { + label: "ソースオブジェクト", + help: "このアクティビティの派生元となったリッチソースエンティティのオブジェクト名(例: \"sys_email\")。null の場合、アクティビティは対象レコード自体に関するものです。" + }, + source_id: { + label: "ソース ID", + help: "リッチソースエンティティのレコード ID(source_object と対で使用)。タイムラインからメール/通話/会議の完全なレコードへドリルダウンできます。" + }, url: { label: "URL", help: "アクティビティターゲットへのオプションのディープリンク" diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 5d23b04bd5..48c0b35c94 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -37,6 +37,10 @@ export const zhCNObjects: NonNullable = { label: "执行人", help: "执行该操作的用户(系统操作时为 null)" }, + actor: { + label: "操作者", + help: "执行该操作的主体:用户 ID、svc: 或空" + }, object_name: { label: "对象", help: "目标对象(例如 sys_user、project_task)" @@ -73,7 +77,11 @@ export const zhCNObjects: NonNullable = { }, _views: { recent: { - label: "最近" + label: "最近", + emptyState: { + title: "暂无审计事件", + message: "用户与平台交互后,动态会显示在这里。" + } }, writes_only: { label: "写入" @@ -140,6 +148,14 @@ export const zhCNObjects: NonNullable = { label: "记录标签", help: "写入时目标记录的显示标签" }, + source_object: { + label: "来源对象", + help: "该动态所派生自的富来源实体的对象名称(如 \"sys_email\")。为空表示动态针对目标记录本身。" + }, + source_id: { + label: "来源 ID", + help: "富来源实体的记录 ID(与 source_object 配对)—— 让时间线可以下钻到完整的邮件/通话/会议记录。" + }, url: { label: "URL", help: "指向活动目标的可选深度链接" diff --git a/packages/plugins/plugin-security/scripts/i18n-extract.config.ts b/packages/plugins/plugin-security/scripts/i18n-extract.config.ts index 17edd7537b..47dad85865 100644 --- a/packages/plugins/plugin-security/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-security/scripts/i18n-extract.config.ts @@ -8,7 +8,7 @@ * strings were seeded from @objectstack/platform-objects.) * * os i18n extract packages/plugins/plugin-security/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ * --out=packages/plugins/plugin-security/src/translations */ diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index d89fe50525..7acd67268e 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -35,6 +35,10 @@ export const enObjects: NonNullable = { label: "Default Position", help: "Automatically assigned to new users" }, + delegatable: { + label: "Delegatable", + help: "ADR-0091 D3: holders may self-service delegate this position, time-boxed." + }, managed_by: { label: "Managed By", help: "Record provenance: platform (built-in) / package (declared) / admin (tenant-created).", @@ -85,7 +89,16 @@ export const enObjects: NonNullable = { }, clone_position: { label: "Clone Position", - successMessage: "Position cloned" + successMessage: "Position cloned", + params: { + label: { + label: "New Display Name" + }, + name: { + label: "New API Name", + helpText: "Unique snake_case machine name" + } + } } } }, @@ -94,6 +107,24 @@ export const enObjects: NonNullable = { pluralLabel: "Capabilities", description: "Authorization capability definitions referenced by name from permission sets and resource requirements.", fields: { + label: { + label: "Display Name" + }, + name: { + label: "API Name", + help: "Unique capability key referenced by systemPermissions / requiredPermissions (e.g. manage_users, setup.access)." + }, + description: { + label: "Description" + }, + scope: { + label: "Scope", + help: "platform = a platform-wide power; org = scoped to an organization.", + options: { + platform: "Platform", + org: "Organization" + } + }, managed_by: { label: "Managed By", help: "Record provenance: platform / package (shipped, not user-deletable) / admin (created in Setup).", @@ -103,12 +134,43 @@ export const enObjects: NonNullable = { admin: "Admin" } }, - scope: { - label: "Scope", - options: { - platform: "Platform", - org: "Organization" - } + package_id: { + label: "Owning Package", + help: "Package that ships this capability (absent = platform-curated or admin-created)." + }, + active: { + label: "Active" + }, + id: { + label: "Capability ID" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + } + }, + _views: { + platform: { + label: "Platform" + }, + org: { + label: "Organization" + }, + all_capabilities: { + label: "All" + } + }, + _actions: { + activate_capability: { + label: "Activate", + successMessage: "Capability activated" + }, + deactivate_capability: { + label: "Deactivate", + confirmText: "Deactivate this capability? Grants and resource requirements that reference it stop resolving until re-activated.", + successMessage: "Capability deactivated" } } }, @@ -147,9 +209,17 @@ export const enObjects: NonNullable = { label: "Tab Permissions", help: "JSON-serialized map of app tab visibility (visible | hidden | default_on | default_off)" }, + admin_scope: { + label: "Delegated Admin Scope", + help: "[ADR-0090 D12] JSON-serialized AdminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Holding this set makes the user a SCOPED administrator within the declared business-unit subtree; enforced by the delegated-admin write gate." + }, active: { label: "Active" }, + package_id: { + label: "Owning Package", + help: "Package that ships this permission set (absent = environment-authored). Populated by bootstrapDeclaredPermissions; makes package uninstall/upgrade well-defined." + }, managed_by: { label: "Managed By", help: "Record provenance: platform (shipped) / package (packaged) / admin (env-authored).", @@ -159,6 +229,10 @@ export const enObjects: NonNullable = { admin: "Admin" } }, + customized: { + label: "Customized", + help: "This packaged permission set has an environment customization overlay (ADR-0094). Reset it (delete through the data door) to return to the shipped baseline." + }, id: { label: "Permission Set ID" }, @@ -192,7 +266,16 @@ export const enObjects: NonNullable = { }, clone_permission_set: { label: "Clone", - successMessage: "Permission set cloned" + successMessage: "Permission set cloned", + params: { + label: { + label: "New Display Name" + }, + name: { + label: "New API Name", + helpText: "Unique snake_case machine name" + } + } } } }, @@ -221,6 +304,30 @@ export const enObjects: NonNullable = { label: "Granted By", help: "User who granted this permission set." }, + valid_from: { + label: "Valid From", + help: "[ADR-0091 D1] Grant is inactive before this instant. Null = active immediately. Enforced fail-closed at resolution time (D2) — never by a background job." + }, + valid_until: { + label: "Valid Until", + help: "[ADR-0091 D1] Grant is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires. Mandatory on break-glass activations (D4) and agent grants (D6). Enforced at resolution time (D2)." + }, + reason: { + label: "Reason", + help: "[ADR-0091 D1] Why this grant exists. Free text; REQUIRED on delegation (D3) and break-glass (D4) rows. Agent grants carry the task/run attribution here (D6)." + }, + delegated_from: { + label: "Delegated From", + help: "[ADR-0091 D3] The delegator whose authority this row carries. A row with delegated_from set is not itself delegatable and not self-renewable." + }, + last_certified_at: { + label: "Last Certified At", + help: "[ADR-0091 D5] When this grant was last attested in a recertification review. Null = never certified." + }, + certified_by: { + label: "Certified By", + help: "[ADR-0091 D5] Reviewer who last attested this grant." + }, created_at: { label: "Created At" }, diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index e58c151f54..ec2b70b7f6 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -35,6 +35,10 @@ export const esESObjects: NonNullable = { label: "Puesto predeterminado", help: "Se asigna automáticamente a los nuevos usuarios." }, + delegatable: { + label: "Delegable", + help: "ADR-0091 D3: quienes la ostentan pueden delegar esta posición por autoservicio, con límite temporal." + }, managed_by: { label: "Gestionado por", help: "Procedencia del registro: platform (integrado) / package (declarado) / admin (creado por el inquilino).", @@ -85,7 +89,16 @@ export const esESObjects: NonNullable = { }, clone_position: { label: "Clonar rol", - successMessage: "Puesto clonado" + successMessage: "Puesto clonado", + params: { + label: { + label: "Nuevo nombre para mostrar" + }, + name: { + label: "Nuevo nombre de API", + helpText: "Nombre de máquina snake_case único" + } + } } } }, @@ -94,6 +107,24 @@ export const esESObjects: NonNullable = { pluralLabel: "Capacidades", description: "Definiciones de capacidades de autorización referenciadas por nombre desde conjuntos de permisos y requisitos de recursos.", fields: { + label: { + label: "Nombre visible" + }, + name: { + label: "Nombre de API", + help: "Clave única de capacidad referenciada por systemPermissions / requiredPermissions (p. ej. manage_users, setup.access)." + }, + description: { + label: "Descripción" + }, + scope: { + label: "Ámbito", + help: "platform = potestad de toda la plataforma; org = limitada a una organización.", + options: { + platform: "Plataforma", + org: "Organización" + } + }, managed_by: { label: "Gestionado por", help: "Procedencia del registro: platform / package (distribuido, no eliminable por el usuario) / admin (creado en Configuración).", @@ -103,12 +134,43 @@ export const esESObjects: NonNullable = { admin: "Administrador" } }, - scope: { - label: "Ámbito", - options: { - platform: "Plataforma", - org: "Organización" - } + package_id: { + label: "Paquete propietario", + help: "Paquete que distribuye esta capacidad (ausente = curada por la plataforma o creada por un administrador)." + }, + active: { + label: "Activo" + }, + id: { + label: "ID de capacidad" + }, + created_at: { + label: "Creado el" + }, + updated_at: { + label: "Actualizado el" + } + }, + _views: { + platform: { + label: "Plataforma" + }, + org: { + label: "Organización" + }, + all_capabilities: { + label: "Todos" + } + }, + _actions: { + activate_capability: { + label: "Activar", + successMessage: "Capacidad activada" + }, + deactivate_capability: { + label: "Desactivar", + confirmText: "¿Desactivar esta capacidad? Las concesiones y los requisitos de recursos que la referencian dejarán de resolverse hasta que se reactive.", + successMessage: "Capacidad desactivada" } } }, @@ -147,9 +209,17 @@ export const esESObjects: NonNullable = { label: "Permisos de pestañas", help: "Mapa serializado en JSON de la visibilidad de las pestañas de la app (visible | hidden | default_on | default_off)" }, + admin_scope: { + label: "Alcance de administración delegada", + help: "[ADR-0090 D12] AdminScope serializado en JSON: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }. Poseer este conjunto convierte al usuario en administrador ACOTADO dentro del subárbol de unidad de negocio declarado; lo aplica la barrera de escritura de administración delegada." + }, active: { label: "Activo" }, + package_id: { + label: "Paquete propietario", + help: "Paquete que distribuye este conjunto de permisos (ausente = creado en el entorno). Lo rellena bootstrapDeclaredPermissions; hace que la desinstalación/actualización del paquete quede bien definida." + }, managed_by: { label: "Gestionado por", help: "Procedencia del registro: platform (distribuido) / package (empaquetado) / admin (creado en el entorno).", @@ -159,6 +229,10 @@ export const esESObjects: NonNullable = { admin: "Administrador" } }, + customized: { + label: "Personalizado", + help: "Este conjunto de permisos empaquetado tiene una superposición de personalización del entorno (ADR-0094). Reinícialo (elimínalo por la puerta de datos) para volver a la línea base distribuida." + }, id: { label: "ID de conjunto de permisos" }, @@ -192,7 +266,16 @@ export const esESObjects: NonNullable = { }, clone_permission_set: { label: "Clonar", - successMessage: "Conjunto de permisos clonado" + successMessage: "Conjunto de permisos clonado", + params: { + label: { + label: "Nuevo nombre para mostrar" + }, + name: { + label: "Nuevo nombre de API", + helpText: "Nombre de máquina snake_case único" + } + } } } }, @@ -221,6 +304,30 @@ export const esESObjects: NonNullable = { label: "Concedido por", help: "Usuario que concedió este conjunto de permisos." }, + valid_from: { + label: "Válido desde", + help: "[ADR-0091 D1] La concesión está inactiva antes de este instante. Nulo = activa de inmediato. Se aplica en modo fail-closed en el momento de resolución (D2), nunca mediante un trabajo en segundo plano." + }, + valid_until: { + label: "Válido hasta", + help: "[ADR-0091 D1] La concesión queda inactiva DESDE este instante inclusive (intervalo semiabierto [from, until), UTC). Null = nunca expira. Obligatorio en activaciones de emergencia (D4) y concesiones de agente (D6). Se aplica en el momento de la resolución (D2)." + }, + reason: { + label: "Motivo", + help: "[ADR-0091 D1] Por qué existe esta concesión. Texto libre; OBLIGATORIO en filas de delegación (D3) y de acceso de emergencia (D4). Las concesiones de agente registran aquí la atribución de tarea/ejecución (D6)." + }, + delegated_from: { + label: "Delegado desde", + help: "[ADR-0091 D3] El delegante cuya autoridad porta esta fila. Una fila con delegated_from no es a su vez delegable ni autorrenovable." + }, + last_certified_at: { + label: "Última certificación", + help: "[ADR-0091 D5] Cuándo se atestiguó por última vez esta concesión en una revisión de recertificación. Nulo = nunca certificada." + }, + certified_by: { + label: "Certificado por", + help: "[ADR-0091 D5] Revisor que atestiguó por última vez esta concesión." + }, created_at: { label: "Creado el" }, diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index 5f935b8ad3..f3468a6932 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -35,6 +35,10 @@ export const jaJPObjects: NonNullable = { label: "デフォルトポジション", help: "新規ユーザーに自動的に割り当てられます" }, + delegatable: { + label: "委任可能", + help: "ADR-0091 D3: 保持者はこのポジションを期限付きでセルフサービス委任できます。" + }, managed_by: { label: "管理元", help: "レコードの出所:platform(組み込み)/ package(宣言済み)/ admin(テナント作成)。", @@ -85,7 +89,16 @@ export const jaJPObjects: NonNullable = { }, clone_position: { label: "ポジションを複製", - successMessage: "ポジションを複製しました" + successMessage: "ポジションを複製しました", + params: { + label: { + label: "新しい表示名" + }, + name: { + label: "新しい API 名", + helpText: "一意の snake_case マシン名" + } + } } } }, @@ -94,6 +107,24 @@ export const jaJPObjects: NonNullable = { pluralLabel: "ケイパビリティ", description: "権限セットおよびリソース要件から名前で参照される認可ケイパビリティ定義。", fields: { + label: { + label: "表示名" + }, + name: { + label: "API 名", + help: "systemPermissions / requiredPermissions から参照される一意のケーパビリティキー(例: manage_users、setup.access)。" + }, + description: { + label: "説明" + }, + scope: { + label: "スコープ", + help: "platform = プラットフォーム全体の権限、org = 組織単位に限定。", + options: { + platform: "プラットフォーム", + org: "組織" + } + }, managed_by: { label: "管理元", help: "レコードの出所:platform / package(配布物、ユーザー削除不可)/ admin(設定で作成)。", @@ -103,12 +134,43 @@ export const jaJPObjects: NonNullable = { admin: "管理者" } }, - scope: { - label: "スコープ", - options: { - platform: "プラットフォーム", - org: "組織" - } + package_id: { + label: "所有パッケージ", + help: "このケーパビリティを提供するパッケージ(未設定はプラットフォーム標準または管理者作成)。" + }, + active: { + label: "有効" + }, + id: { + label: "ケーパビリティ ID" + }, + created_at: { + label: "作成日時" + }, + updated_at: { + label: "更新日時" + } + }, + _views: { + platform: { + label: "プラットフォーム" + }, + org: { + label: "組織" + }, + all_capabilities: { + label: "すべて" + } + }, + _actions: { + activate_capability: { + label: "有効化", + successMessage: "ケーパビリティを有効化しました" + }, + deactivate_capability: { + label: "無効化", + confirmText: "このケーパビリティを無効化しますか?これを参照する付与とリソース要件は、再度有効化するまで解決されなくなります。", + successMessage: "ケーパビリティを無効化しました" } } }, @@ -147,9 +209,17 @@ export const jaJPObjects: NonNullable = { label: "タブ権限", help: "アプリのタブ表示のJSONシリアライズマップ(visible | hidden | default_on | default_off)" }, + admin_scope: { + label: "委任管理スコープ", + help: "[ADR-0090 D12] JSON シリアライズされた AdminScope: { businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }。このセットを保持すると、宣言されたビジネスユニット配下に限定されたスコープ付き管理者になります。委任管理の書き込みゲートで強制されます。" + }, active: { label: "有効" }, + package_id: { + label: "所有パッケージ", + help: "この権限セットを提供するパッケージ(未設定は環境で作成)。bootstrapDeclaredPermissions が設定し、パッケージのアンインストール/アップグレードの挙動を明確にします。" + }, managed_by: { label: "管理元", help: "レコードの出所:platform(配布)/ package(パッケージ)/ admin(環境作成)。", @@ -159,6 +229,10 @@ export const jaJPObjects: NonNullable = { admin: "管理者" } }, + customized: { + label: "カスタマイズ済み", + help: "このパッケージ権限セットには環境カスタマイズのオーバーレイがあります(ADR-0094)。リセット(データドア経由で削除)すると出荷時のベースラインに戻ります。" + }, id: { label: "権限セット ID" }, @@ -192,7 +266,16 @@ export const jaJPObjects: NonNullable = { }, clone_permission_set: { label: "複製", - successMessage: "権限セットを複製しました" + successMessage: "権限セットを複製しました", + params: { + label: { + label: "新しい表示名" + }, + name: { + label: "新しい API 名", + helpText: "一意の snake_case マシン名" + } + } } } }, @@ -221,6 +304,30 @@ export const jaJPObjects: NonNullable = { label: "付与者", help: "この権限セットを付与したユーザー。" }, + valid_from: { + label: "有効開始", + help: "[ADR-0091 D1] この時点より前は付与が無効です。Null = 即時有効。解決時にフェイルクローズで適用されます(D2)——バックグラウンドジョブでは行いません。" + }, + valid_until: { + label: "有効終了", + help: "[ADR-0091 D1] この時点以降(当該時点を含む)、付与は無効になります(半開区間 [from, until)、UTC)。null は無期限。ブレークグラス発動(D4)とエージェント付与(D6)では必須。解決時に強制されます(D2)。" + }, + reason: { + label: "理由", + help: "[ADR-0091 D1] この付与が存在する理由。自由記述。委任(D3)とブレークグラス(D4)の行では必須。エージェント付与はここにタスク/実行の帰属を記録します(D6)。" + }, + delegated_from: { + label: "委任元", + help: "[ADR-0091 D3] この行が引き継ぐ権限の委任元。delegated_from が設定された行は、それ自体を再委任することも自己更新することもできません。" + }, + last_certified_at: { + label: "最終認証日時", + help: "[ADR-0091 D5] この付与が再認証レビューで最後に証明された日時。Null = 未認証。" + }, + certified_by: { + label: "認証者", + help: "[ADR-0091 D5] この付与を最後に証明したレビュアー。" + }, created_at: { label: "作成日時" }, diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index cdda5a2b3f..35826b91ce 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -35,6 +35,10 @@ export const zhCNObjects: NonNullable = { label: "默认岗位", help: "自动分配给新用户" }, + delegatable: { + label: "可委派", + help: "ADR-0091 D3:持有者可自助委派该岗位,且有时限。" + }, managed_by: { label: "管理来源", help: "记录来源:platform(平台内置)/ package(应用包声明)/ admin(租户创建)。", @@ -85,7 +89,16 @@ export const zhCNObjects: NonNullable = { }, clone_position: { label: "克隆岗位", - successMessage: "已克隆岗位" + successMessage: "已克隆岗位", + params: { + label: { + label: "新显示名称" + }, + name: { + label: "新 API 名称", + helpText: "唯一的 snake_case 机器名称" + } + } } } }, @@ -94,6 +107,24 @@ export const zhCNObjects: NonNullable = { pluralLabel: "能力", description: "授权能力定义,由权限集和资源需求按名称引用。", fields: { + label: { + label: "显示名称" + }, + name: { + label: "API 名称", + help: "唯一的能力键,由 systemPermissions / requiredPermissions 按名称引用(如 manage_users、setup.access)。" + }, + description: { + label: "描述" + }, + scope: { + label: "范围", + help: "platform = 平台级权能;org = 限定于某个组织。", + options: { + platform: "平台", + org: "组织" + } + }, managed_by: { label: "管理来源", help: "记录来源:platform / package(随包发布,不可用户删除)/ admin(在设置中创建)。", @@ -103,12 +134,43 @@ export const zhCNObjects: NonNullable = { admin: "管理员" } }, - scope: { - label: "范围", - options: { - platform: "平台", - org: "组织" - } + package_id: { + label: "所属应用包", + help: "发布该能力的应用包(为空表示平台内置或管理员创建)。" + }, + active: { + label: "启用" + }, + id: { + label: "能力 ID" + }, + created_at: { + label: "创建时间" + }, + updated_at: { + label: "更新时间" + } + }, + _views: { + platform: { + label: "平台" + }, + org: { + label: "组织" + }, + all_capabilities: { + label: "全部" + } + }, + _actions: { + activate_capability: { + label: "激活", + successMessage: "已启用能力" + }, + deactivate_capability: { + label: "停用", + confirmText: "停用该能力?引用它的授权和资源要求将无法解析,直到重新启用。", + successMessage: "已停用能力" } } }, @@ -147,9 +209,17 @@ export const zhCNObjects: NonNullable = { label: "标签页权限", help: "应用标签页可见性的 JSON 序列化映射(visible | hidden | default_on | default_off)" }, + admin_scope: { + label: "委派管理范围", + help: "[ADR-0090 D12] JSON 序列化的 AdminScope:{ businessUnit, includeSubtree, manageAssignments, manageBindings, authorEnvironmentSets, assignablePermissionSets[] }。持有该权限集会使用户成为所声明业务单元子树内的「受限范围」管理员,由委派管理写入门禁强制执行。" + }, active: { label: "启用" }, + package_id: { + label: "所属应用包", + help: "发布该权限集的应用包(为空表示环境内编写)。由 bootstrapDeclaredPermissions 填充,使应用包的卸载/升级行为有明确定义。" + }, managed_by: { label: "管理来源", help: "记录来源:platform(平台发布)/ package(应用包发布)/ admin(环境自建)。", @@ -159,6 +229,10 @@ export const zhCNObjects: NonNullable = { admin: "管理员" } }, + customized: { + label: "已定制", + help: "该打包权限集存在环境定制覆盖层(ADR-0094)。重置它(通过数据入口删除)即可回到随包发布的基线。" + }, id: { label: "权限集 ID" }, @@ -192,7 +266,16 @@ export const zhCNObjects: NonNullable = { }, clone_permission_set: { label: "克隆", - successMessage: "已克隆权限集" + successMessage: "已克隆权限集", + params: { + label: { + label: "新显示名称" + }, + name: { + label: "新 API 名称", + helpText: "唯一的 snake_case 机器名称" + } + } } } }, @@ -221,6 +304,30 @@ export const zhCNObjects: NonNullable = { label: "授权人", help: "授予该权限集的用户。" }, + valid_from: { + label: "生效时间", + help: "[ADR-0091 D1] 在此时刻之前授予无效。为空 = 立即生效。在解析时以 fail-closed 方式强制执行(D2)——绝不依赖后台任务。" + }, + valid_until: { + label: "失效时间", + help: "[ADR-0091 D1] 授权自该时刻起(含)失效(半开区间 [from, until),UTC)。为空表示永不过期。紧急提权(D4)和智能体授权(D6)必填。在解析时强制执行(D2)。" + }, + reason: { + label: "原因", + help: "[ADR-0091 D1] 该授权存在的原因。自由文本;委派(D3)和紧急提权(D4)记录必填。智能体授权在此记录任务/运行归属(D6)。" + }, + delegated_from: { + label: "委派自", + help: "[ADR-0091 D3] 该记录所承载权限的委派人。设置了 delegated_from 的记录本身不可再委派,也不可自助续期。" + }, + last_certified_at: { + label: "最近认证时间", + help: "[ADR-0091 D5] 此授予在重新认证复核中最近一次被认证的时间。为空 = 从未认证。" + }, + certified_by: { + label: "认证人", + help: "[ADR-0091 D5] 最近一次认证此授予的复核人。" + }, created_at: { label: "创建时间" }, diff --git a/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts b/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts index 7c3e56cc1c..2782f06f95 100644 --- a/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts @@ -8,7 +8,7 @@ * strings were seeded from @objectstack/platform-objects.) * * os i18n extract packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ * --out=packages/plugins/plugin-sharing/src/translations */ diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index a3c8124195..845553397a 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -156,6 +156,19 @@ export const enObjects: NonNullable = { label: "Active", help: "Only active rules participate in lifecycle evaluation" }, + managed_by: { + label: "Managed By", + help: "Record provenance (unified tri-state, A4 #2920): platform = framework built-in / package = app/package-declared (boot-seeded) / admin = tenant-created in Setup.", + options: { + platform: "Platform", + package: "Package", + admin: "Admin" + } + }, + customized: { + label: "Customized", + help: "Set when an admin edits a package-declared rule; boot seeding will no longer overwrite the row (deactivations survive redeploys). Meaningless on admin rows." + }, created_at: { label: "Created At" }, diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index f06d613ca1..de1566ca62 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -135,9 +135,9 @@ export const esESObjects: NonNullable = { options: { user: "Usuario", team: "Equipo", - business_unit: "business_unit", + business_unit: "Unidad de negocio", position: "posición", - unit_and_subordinates: "unit_and_subordinates" + unit_and_subordinates: "Rol y subordinados" } }, recipient_id: { @@ -156,6 +156,19 @@ export const esESObjects: NonNullable = { label: "Activo", help: "Solo las reglas activas participan en la evaluación del ciclo de vida." }, + managed_by: { + label: "Gestionado por", + help: "Procedencia del registro (tri-estado unificado, A4 #2920): platform = integrado en el framework / package = declarado por app o paquete (sembrado al arrancar) / admin = creado por el inquilino en Setup.", + options: { + platform: "Plataforma", + package: "Paquete", + admin: "Administrador" + } + }, + customized: { + label: "Personalizado", + help: "Se establece cuando un administrador edita una regla declarada por un paquete; a partir de entonces el sembrado de arranque deja de sobrescribir la fila (las desactivaciones sobreviven a los redespliegues). Sin sentido en filas admin." + }, created_at: { label: "Creado el" }, diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index 7b8f076d66..e35b851c0f 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -135,9 +135,9 @@ export const jaJPObjects: NonNullable = { options: { user: "ユーザー", team: "チーム", - business_unit: "business_unit", + business_unit: "ビジネスユニット", position: "ポジション", - unit_and_subordinates: "unit_and_subordinates" + unit_and_subordinates: "ビジネスユニットと下位階層" } }, recipient_id: { @@ -156,6 +156,19 @@ export const jaJPObjects: NonNullable = { label: "有効", help: "有効なルールのみがライフサイクル評価に参加します" }, + managed_by: { + label: "管理元", + help: "レコードの出所(統一三状態、A4 #2920): platform = フレームワーク組み込み / package = アプリ・パッケージ宣言(起動時シード)/ admin = テナントが Setup で作成。", + options: { + platform: "プラットフォーム", + package: "パッケージ", + admin: "管理者" + } + }, + customized: { + label: "カスタマイズ済み", + help: "管理者がパッケージ宣言のルールを編集すると設定されます。以後、起動時シードはこの行を上書きしません(無効化は再デプロイ後も保持)。admin 行では意味を持ちません。" + }, created_at: { label: "作成日時" }, diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 205e6770e1..5ca5ac63e5 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -135,9 +135,9 @@ export const zhCNObjects: NonNullable = { options: { user: "用户", team: "团队", - business_unit: "business_unit", + business_unit: "业务单元", position: "岗位", - unit_and_subordinates: "unit_and_subordinates" + unit_and_subordinates: "业务单元及下级" } }, recipient_id: { @@ -156,6 +156,19 @@ export const zhCNObjects: NonNullable = { label: "启用", help: "只有启用的规则才会参与生命周期求值" }, + managed_by: { + label: "管理来源", + help: "记录来源(统一三态,A4 #2920):platform = 框架内置 / package = 应用包声明(启动时种入)/ admin = 租户在 Setup 中创建。", + options: { + platform: "平台", + package: "应用包", + admin: "管理员" + } + }, + customized: { + label: "已定制", + help: "当管理员编辑应用包声明的规则时置位;此后启动种入不再覆盖该行(停用状态可跨重新部署保留)。对 admin 来源的记录无意义。" + }, created_at: { label: "创建时间" }, diff --git a/packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts b/packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts index 12dd677795..e36a892006 100644 --- a/packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts @@ -8,7 +8,7 @@ * strings were seeded from @objectstack/platform-objects.) * * os i18n extract packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ * --out=packages/plugins/plugin-webhooks/src/translations */ diff --git a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts index 425895ecfc..4afb76f019 100644 --- a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts @@ -30,7 +30,12 @@ export const enObjects: NonNullable = { }, triggers: { label: "Triggers", - help: "Comma-separated event list: create,update,delete" + help: "Comma-separated event list: create,update,delete", + options: { + create: "create", + update: "update", + delete: "delete" + } }, url: { label: "Target URL", @@ -38,7 +43,14 @@ export const enObjects: NonNullable = { }, method: { label: "HTTP Method", - help: "GET / POST / PUT / PATCH / DELETE" + help: "GET / POST / PUT / PATCH / DELETE", + options: { + get: "GET", + post: "POST", + put: "PUT", + patch: "PATCH", + delete: "DELETE" + } }, description: { label: "Description" @@ -51,6 +63,19 @@ export const enObjects: NonNullable = { label: "Definition", help: "Serialised Webhook JSON (see @objectstack/spec/automation/webhook) — full headers/auth/retry/payload config" }, + managed_by: { + label: "Managed By", + help: "Record provenance: platform = framework built-in / package = app/package-declared (boot-seeded from defineStack webhooks) / admin = created in Setup.", + options: { + platform: "platform", + package: "package", + admin: "admin" + } + }, + customized: { + label: "Customized", + help: "Set when an admin edits a package-declared webhook; boot seeding will no longer overwrite the row (a deactivated noisy webhook survives redeploys). Meaningless on admin rows." + }, created_at: { label: "Created At" }, diff --git a/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts index 99a5576520..f4f3b4b132 100644 --- a/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts @@ -30,7 +30,12 @@ export const esESObjects: NonNullable = { }, triggers: { label: "Desencadenantes", - help: "Lista de eventos separada por comas: create,update,delete." + help: "Lista de eventos separada por comas: create,update,delete.", + options: { + create: "Crear", + update: "Actualizar", + delete: "Eliminar" + } }, url: { label: "URL de destino", @@ -38,7 +43,14 @@ export const esESObjects: NonNullable = { }, method: { label: "Método HTTP", - help: "GET / POST / PUT / PATCH / DELETE" + help: "GET / POST / PUT / PATCH / DELETE", + options: { + get: "GET", + post: "POST", + put: "PUT", + patch: "PATCH", + delete: "DELETE" + } }, description: { label: "Descripción" @@ -51,6 +63,19 @@ export const esESObjects: NonNullable = { label: "Definición", help: "JSON serializado de Webhook (consulte @objectstack/spec/automation/webhook): configuración completa de cabeceras/auth/reintentos/payload." }, + managed_by: { + label: "Gestionado por", + help: "Procedencia del registro: platform = integrado en el framework / package = declarado por app o paquete (sembrado al arrancar desde los webhooks de defineStack) / admin = creado en Setup.", + options: { + platform: "Plataforma", + package: "Paquete", + admin: "Administrador" + } + }, + customized: { + label: "Personalizado", + help: "Se establece cuando un administrador edita un webhook declarado por un paquete; a partir de entonces el sembrado de arranque deja de sobrescribir la fila (un webhook ruidoso desactivado sobrevive a los redespliegues). Sin sentido en filas admin." + }, created_at: { label: "Creado el" }, diff --git a/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts index 4e684b7d28..ad7d56af47 100644 --- a/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts @@ -30,7 +30,12 @@ export const jaJPObjects: NonNullable = { }, triggers: { label: "トリガー", - help: "カンマ区切りのイベントリスト: create,update,delete" + help: "カンマ区切りのイベントリスト: create,update,delete", + options: { + create: "作成", + update: "更新", + delete: "削除" + } }, url: { label: "ターゲット URL", @@ -38,7 +43,14 @@ export const jaJPObjects: NonNullable = { }, method: { label: "HTTP メソッド", - help: "GET / POST / PUT / PATCH / DELETE" + help: "GET / POST / PUT / PATCH / DELETE", + options: { + get: "GET", + post: "POST", + put: "PUT", + patch: "PATCH", + delete: "DELETE" + } }, description: { label: "説明" @@ -51,6 +63,19 @@ export const jaJPObjects: NonNullable = { label: "定義", help: "シリアライズされた Webhook JSON(@objectstack/spec/automation/webhook 参照)— ヘッダー/認証/リトライ/ペイロード設定を含む" }, + managed_by: { + label: "管理元", + help: "レコードの出所: platform = フレームワーク組み込み / package = アプリ・パッケージ宣言(defineStack の webhooks から起動時シード)/ admin = Setup で作成。", + options: { + platform: "プラットフォーム", + package: "パッケージ", + admin: "管理者" + } + }, + customized: { + label: "カスタマイズ済み", + help: "管理者がパッケージ宣言の webhook を編集すると設定されます。以後、起動時シードはこの行を上書きしません(無効化したノイジーな webhook は再デプロイ後も保持)。admin 行では意味を持ちません。" + }, created_at: { label: "作成日時" }, diff --git a/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts index a3f4eb63b3..d4a1f50811 100644 --- a/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts @@ -30,7 +30,12 @@ export const zhCNObjects: NonNullable = { }, triggers: { label: "触发器", - help: "以逗号分隔的事件列表:create,update,delete" + help: "以逗号分隔的事件列表:create,update,delete", + options: { + create: "创建", + update: "更新", + delete: "删除" + } }, url: { label: "目标 URL", @@ -38,7 +43,14 @@ export const zhCNObjects: NonNullable = { }, method: { label: "HTTP 方法", - help: "GET / POST / PUT / PATCH / DELETE" + help: "GET / POST / PUT / PATCH / DELETE", + options: { + get: "GET", + post: "POST", + put: "PUT", + patch: "PATCH", + delete: "DELETE" + } }, description: { label: "描述" @@ -51,6 +63,19 @@ export const zhCNObjects: NonNullable = { label: "定义", help: "序列化的 Webhook JSON(参见 @objectstack/spec/automation/webhook)——包含完整的 headers/auth/retry/payload 配置" }, + managed_by: { + label: "管理来源", + help: "记录来源:platform = 框架内置 / package = 应用包声明(由 defineStack 的 webhooks 在启动时种入)/ admin = 在 Setup 中创建。", + options: { + platform: "平台", + package: "包", + admin: "管理员" + } + }, + customized: { + label: "已定制", + help: "当管理员编辑应用包声明的 webhook 时置位;此后启动种入不再覆盖该行(被停用的嘈杂 webhook 可跨重新部署保留)。对 admin 来源的记录无意义。" + }, created_at: { label: "创建时间" }, diff --git a/packages/services/service-messaging/scripts/i18n-extract.config.ts b/packages/services/service-messaging/scripts/i18n-extract.config.ts index 175a3dab69..4bf88f444f 100644 --- a/packages/services/service-messaging/scripts/i18n-extract.config.ts +++ b/packages/services/service-messaging/scripts/i18n-extract.config.ts @@ -7,7 +7,7 @@ * `--merge` preserves every hand-translated string. * * os i18n extract packages/services/service-messaging/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ * --out=packages/services/service-messaging/src/translations */ diff --git a/packages/services/service-realtime/scripts/i18n-extract.config.ts b/packages/services/service-realtime/scripts/i18n-extract.config.ts index 8c34095ef4..51e3749d9a 100644 --- a/packages/services/service-realtime/scripts/i18n-extract.config.ts +++ b/packages/services/service-realtime/scripts/i18n-extract.config.ts @@ -8,7 +8,7 @@ * strings were seeded from @objectstack/platform-objects.) * * os i18n extract packages/services/service-realtime/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ * --out=packages/services/service-realtime/src/translations */ diff --git a/packages/services/service-storage/scripts/i18n-extract.config.ts b/packages/services/service-storage/scripts/i18n-extract.config.ts index 68b2a13c4b..9966fa0e71 100644 --- a/packages/services/service-storage/scripts/i18n-extract.config.ts +++ b/packages/services/service-storage/scripts/i18n-extract.config.ts @@ -11,7 +11,7 @@ * decomposition (see that package's extract config). * * os i18n extract packages/services/service-storage/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \ + * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ * --out=packages/services/service-storage/src/translations */ diff --git a/packages/services/service-storage/src/translations/en.objects.generated.ts b/packages/services/service-storage/src/translations/en.objects.generated.ts index 7a6a7074d4..23f0f829fa 100644 --- a/packages/services/service-storage/src/translations/en.objects.generated.ts +++ b/packages/services/service-storage/src/translations/en.objects.generated.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** * Auto-generated by 'os i18n extract' for locale 'en'. @@ -62,7 +62,20 @@ export const enObjects: NonNullable = { label: "ETag" }, owner_id: { - label: "Owner ID" + label: "Owner ID", + help: "User who uploaded the file (authorship, not the field-reference owner)" + }, + ref_object: { + label: "Referencing Object", + help: "Short object name of the record whose field owns this file" + }, + ref_id: { + label: "Referencing Record", + help: "Primary key of the record whose field owns this file" + }, + ref_field: { + label: "Referencing Field", + help: "Field name on the owning record that holds this file id" }, metadata: { label: "Metadata (JSON)" diff --git a/packages/services/service-storage/src/translations/es-ES.objects.generated.ts b/packages/services/service-storage/src/translations/es-ES.objects.generated.ts index cd43509e4e..c4bac9cb7a 100644 --- a/packages/services/service-storage/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-storage/src/translations/es-ES.objects.generated.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** * Auto-generated by 'os i18n extract' for locale 'es-ES'. @@ -62,7 +62,20 @@ export const esESObjects: NonNullable = { label: "ETag" }, owner_id: { - label: "ID de propietario" + label: "ID de propietario", + help: "Usuario que subió el archivo (autoría, no el propietario de la referencia de campo)" + }, + ref_object: { + label: "Objeto referenciador", + help: "Nombre corto de objeto del registro cuyo campo posee este archivo" + }, + ref_id: { + label: "Registro referenciador", + help: "Clave primaria del registro cuyo campo posee este archivo" + }, + ref_field: { + label: "Campo referenciador", + help: "Nombre del campo del registro propietario que contiene este id de archivo" }, metadata: { label: "Metadatos (JSON)" diff --git a/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts b/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts index 9cf2fc10a3..5ef7b54973 100644 --- a/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-storage/src/translations/ja-JP.objects.generated.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** * Auto-generated by 'os i18n extract' for locale 'ja-JP'. @@ -62,7 +62,20 @@ export const jaJPObjects: NonNullable = { label: "ETag" }, owner_id: { - label: "所有者 ID" + label: "所有者 ID", + help: "ファイルをアップロードしたユーザー(作成者であり、フィールド参照の所有者ではありません)" + }, + ref_object: { + label: "参照元オブジェクト", + help: "このファイルを保持するフィールドを持つレコードのオブジェクト短縮名" + }, + ref_id: { + label: "参照元レコード", + help: "このファイルを保持するフィールドを持つレコードの主キー" + }, + ref_field: { + label: "参照元フィールド", + help: "このファイル ID を保持する、所有レコード側のフィールド名" }, metadata: { label: "メタデータ(JSON)" diff --git a/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts b/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts index 6cd26dfd31..b4b60cabe6 100644 --- a/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-storage/src/translations/zh-CN.objects.generated.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** * Auto-generated by 'os i18n extract' for locale 'zh-CN'. @@ -62,7 +62,20 @@ export const zhCNObjects: NonNullable = { label: "ETag" }, owner_id: { - label: "所有者 ID" + label: "所有者 ID", + help: "上传该文件的用户(表示作者身份,而非字段引用的所属方)" + }, + ref_object: { + label: "引用方对象", + help: "拥有该文件的字段所在记录的对象短名称" + }, + ref_id: { + label: "引用方记录", + help: "拥有该文件的字段所在记录的主键" + }, + ref_field: { + label: "引用方字段", + help: "所属记录上保存该文件 ID 的字段名" }, metadata: { label: "元数据(JSON)" diff --git a/scripts/check-i18n-bundles.mjs b/scripts/check-i18n-bundles.mjs new file mode 100644 index 0000000000..d7a09981d2 --- /dev/null +++ b/scripts/check-i18n-bundles.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +// check-i18n-bundles — drift gate for EVERY package that owns a translation bundle. +// +// `pnpm check:i18n` guarded exactly one package (platform-objects) while eight +// others shipped an `i18n-extract.config.ts` that nothing ever ran. Four of +// them had already drifted out of sync with the schema unnoticed — bundles +// carrying keys the schema had renamed away, and missing keys it had gained. +// That is the same silent-staleness this gate exists to prevent, just outside +// the one directory it happened to cover. +// +// The command each package is checked with is not repeated here: it is parsed +// out of the config file's own docstring, which already documents how to +// regenerate that bundle. Executing exactly the documented command means the +// docs and the gate cannot diverge — the same reason `os lint`'s coverage +// detector was made to share the extractor's walker (#3370). +// +// node scripts/check-i18n-bundles.mjs # check all, fail on drift +// node scripts/check-i18n-bundles.mjs --write # regenerate in place +// node scripts/check-i18n-bundles.mjs --filter=security +// +// Requires the workspace build (it runs the built CLI), so it belongs after +// the build step with the other consumer gates. +import { execFileSync } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const CLI = 'packages/cli/bin/run.js'; +const write = process.argv.includes('--write'); +const filterArg = process.argv.find((a) => a.startsWith('--filter=')); +const filter = filterArg ? filterArg.slice('--filter='.length) : ''; + +/** Every `scripts/i18n-extract.config.ts` under packages/. */ +function findConfigs(dir, out = []) { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue; + const p = join(dir, e.name); + if (e.isDirectory()) findConfigs(p, out); + else if (e.name === 'i18n-extract.config.ts' && p.includes('/scripts/')) out.push(p); + } + return out; +} + +/** + * Read the regenerate command the config documents about itself. Every flag the + * gate passes comes from there, so a package that changes its locales or output + * directory updates one place and the gate follows. + */ +function flagsFromDocstring(configPath) { + const src = readFileSync(configPath, 'utf8'); + const head = src.slice(0, src.indexOf('*/') + 2); + const flags = head.match(/--(?:locales|fill|out)=[^\s\\*]+|--(?:objects-only|no-metadata-forms|no-merge)\b/g) ?? []; + return [...new Set(flags)]; +} + +const configs = findConfigs('packages').sort().filter((c) => !filter || c.includes(filter)); +if (configs.length === 0) { + console.error(`check-i18n-bundles: no extract configs matched${filter ? ` --filter=${filter}` : ''}`); + process.exit(1); +} + +const drifted = []; +const broken = []; +for (const config of configs) { + const pkg = config.replace(/^packages\//, '').replace(/\/scripts\/i18n-extract\.config\.ts$/, ''); + const flags = flagsFromDocstring(config); + const out = flags.find((f) => f.startsWith('--out=')); + if (!out) { + broken.push(`${pkg}: its docstring documents no --out=, so the gate cannot tell where the bundles live`); + continue; + } + const outDir = out.slice('--out='.length); + if (!existsSync(outDir)) { + broken.push(`${pkg}: documented --out directory does not exist: ${outDir}`); + continue; + } + const args = [CLI, 'i18n', 'extract', config, ...flags, ...(write ? [] : ['--check'])]; + let stdout = ''; + let failed = false; + try { + stdout = execFileSync(process.execPath, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + } catch (err) { + stdout = err.stdout ?? ''; + failed = true; + } + if (write) { + console.log(` ${pkg.padEnd(30)} regenerated`); + continue; + } + if (failed) { + const stale = [...stdout.matchAll(/(?:out of date|missing):\s+(\S+)/g)].map((m) => m[1]); + if (stale.length) { + drifted.push(`${pkg}: ${stale.length} bundle(s) drifted from the schema`); + console.log(` ${pkg.padEnd(30)} DRIFTED (${stale.length})`); + } else { + // Not a drift result — the extract itself blew up. Never report that as + // a pass; a config that cannot load is a broken gate, not a clean one. + broken.push(`${pkg}: extract failed — ${(stdout.trim().split('\n').pop() || 'no output').trim()}`); + console.log(` ${pkg.padEnd(30)} ERROR`); + } + continue; + } + const n = (stdout.match(/(\d+) bundle\(s\) are in sync/) ?? [, '?'])[1]; + console.log(` ${pkg.padEnd(30)} in sync (${n} bundle(s))`); +} + +if (write) process.exit(0); + +if (broken.length || drifted.length) { + console.error(`\ncheck-i18n-bundles: ${broken.length + drifted.length} problem(s)\n`); + for (const b of broken) console.error(' • ' + b); + for (const d of drifted) { + console.error(' • ' + d); + } + if (drifted.length) { + console.error( + `\nRegenerate and commit: node scripts/check-i18n-bundles.mjs --write\n` + + `Merge mode is on, so no existing translation is overwritten — new schema keys are\n` + + `added filled with the source text, and they still need translating.`, + ); + } + process.exit(1); +} +console.log(`\ncheck-i18n-bundles: OK (${configs.length} package(s), all bundles in sync).`); diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs index 3962e10d51..6262e85c82 100644 --- a/scripts/check-i18n-coverage.mjs +++ b/scripts/check-i18n-coverage.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -// check-i18n-coverage — declared-label translation ratchet for the bundled examples. +// check-i18n-coverage — declared-label translation ratchet for the examples AND +// every package that owns a translation bundle. // // #3370 made `os lint` gate the WHOLE declared surface (inline object actions, // action params / resultDialog, listViews, apps / dashboards / pages), not just @@ -30,9 +31,12 @@ // Requires the workspace build (it runs the built CLI), so it belongs after the // build step with the other consumer gates. import { execFileSync } from 'node:child_process'; -import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { readdirSync, readFileSync, writeFileSync, existsSync, openSync, closeSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +// NOTE: covers both `examples/*` and every package with an extract config. const EXAMPLES_DIR = 'examples'; const BASELINE_PATH = 'scripts/i18n-coverage-baseline.json'; const CLI = 'packages/cli/bin/run.js'; @@ -49,37 +53,74 @@ function discoverExamples() { .sort(); } -/** Untranslated declared strings `os lint` would show for one config. */ +/** + * Every package that owns a translation bundle, via the same + * `scripts/i18n-extract.config.ts` its drift gate uses. + * + * These matter more than the examples: an example is a demo, but a platform + * package's untranslated label is what a customer actually reads in Setup / + * Studio. Covering only `examples/` is how `platform-objects` sat on 77 + * untranslated navigation and widget labels per locale without anything saying so. + */ +function discoverPackages(dir = 'packages', out = []) { + if (!existsSync(dir)) return out; + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue; + const p = join(dir, e.name); + if (e.isDirectory()) discoverPackages(p, out); + else if (e.name === 'i18n-extract.config.ts' && p.includes('/scripts/')) out.push(p); + } + return out.sort(); +} + +/** + * Untranslated declared strings `os lint` would show for one config. + * + * Captured to a FILE rather than a pipe. Node writes stdout synchronously to a + * file and asynchronously to a pipe, so a command that exits right after a + * large `console.log` can hand a pipe reader a payload cut off at one 64 KiB + * buffer — which is exactly what `os lint --json` did on `platform-objects` + * until the `emitJson` fix. A gate must never be able to read a truncated + * payload and quietly report a smaller number, so it does not use a pipe at all. + */ function countI18nIssues(configPath) { - let stdout; + const tmp = join(tmpdir(), `os-lint-${randomUUID()}.json`); + const fd = openSync(tmp, 'w'); try { - stdout = execFileSync(process.execPath, [CLI, 'lint', configPath, '--json'], { - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - }); - } catch (err) { - // `os lint` exits non-zero when the config has errors of any kind; the JSON - // payload is still on stdout and is what we want. A genuinely broken run - // (no stdout) is a hard failure — never silently a zero. - stdout = err.stdout ?? ''; - if (!stdout.trim()) { - throw new Error(`os lint produced no output for ${configPath}: ${err.stderr || err.message}`); + try { + execFileSync(process.execPath, [CLI, 'lint', configPath, '--json'], { + stdio: ['ignore', fd, 'pipe'], + }); + } catch { + // `os lint` exits non-zero whenever the config has errors of any kind; + // the JSON payload is still what we want. A run that produced no output + // is a hard failure — never silently a zero. + } + closeSync(fd); + const raw = readFileSync(tmp, 'utf8'); + if (!raw.trim()) throw new Error(`os lint produced no output for ${configPath}`); + let report; + try { + report = JSON.parse(raw); + } catch (err) { + throw new Error(`os lint produced unparseable JSON for ${configPath} (${raw.length} bytes): ${err.message}`); } + if (report.error) throw new Error(`os lint failed for ${configPath}: ${report.error}`); + const issues = report.issues ?? []; + return issues.filter((i) => typeof i.rule === 'string' && i.rule.startsWith('i18n/')).length; + } finally { + try { unlinkSync(tmp); } catch { /* already gone */ } } - const report = JSON.parse(stdout); - if (report.error) throw new Error(`os lint failed for ${configPath}: ${report.error}`); - const issues = report.issues ?? []; - return issues.filter((i) => typeof i.rule === 'string' && i.rule.startsWith('i18n/')).length; } const current = {}; -for (const configPath of discoverExamples()) { +for (const configPath of [...discoverExamples(), ...discoverPackages()]) { current[configPath] = countI18nIssues(configPath); } if (update) { writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n'); - console.log(`i18n coverage baseline updated: ${Object.keys(current).length} example(s).`); + console.log(`i18n coverage baseline updated: ${Object.keys(current).length} config(s).`); process.exit(0); } @@ -90,14 +131,14 @@ for (const [file, count] of Object.entries(current)) { const allowed = baseline[file]; if (allowed === undefined) { errors.push( - `${file}: new example is not baselined (${count} untranslated declared string(s)). ` + + `${file}: not baselined (${count} untranslated declared string(s)). ` + `Translate them, or run \`node scripts/check-i18n-coverage.mjs --update\` to freeze the debt.`, ); } else if (count > allowed) { errors.push( `${file}: untranslated declared strings grew ${allowed} → ${count}. ` + `Something declared a label without translating it for a locale this example claims to support ` + - `(see \`i18n.supportedLocales\`). Run \`os i18n extract\` and fill the new keys, or \`os lint ${file}\` to list them.`, + `(see \`i18n.supportedLocales\`). Run \`node scripts/check-i18n-bundles.mjs --write\` to scaffold the new keys, then translate them; \`os lint ${file}\` lists them.`, ); } } @@ -105,7 +146,7 @@ for (const [file, allowed] of Object.entries(baseline)) { const now = current[file]; if (now === undefined) { errors.push( - `${file}: baselined example is gone (was ${allowed}) — ratchet DOWN: ` + + `${file}: baselined config is gone (was ${allowed}) — ratchet DOWN: ` + `run \`node scripts/check-i18n-coverage.mjs --update\` and commit the baseline.`, ); } else if (now < allowed) { @@ -123,5 +164,5 @@ if (errors.length) { } const total = Object.values(current).reduce((a, b) => a + b, 0); console.log( - `check-i18n-coverage: OK (${Object.keys(current).length} example(s), ${total} baselined untranslated string(s), none new).`, + `check-i18n-coverage: OK (${Object.keys(current).length} config(s), ${total} baselined untranslated string(s), none new).`, ); diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 289c2fcab1..2622fe39ac 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,5 +1,14 @@ { "examples/app-crm/objectstack.config.ts": 97, "examples/app-showcase/objectstack.config.ts": 456, - "examples/app-todo/objectstack.config.ts": 212 + "examples/app-todo/objectstack.config.ts": 212, + "packages/platform-objects/scripts/i18n-extract.config.ts": 231, + "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0, + "packages/plugins/plugin-audit/scripts/i18n-extract.config.ts": 0, + "packages/plugins/plugin-security/scripts/i18n-extract.config.ts": 0, + "packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts": 0, + "packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts": 0, + "packages/services/service-messaging/scripts/i18n-extract.config.ts": 0, + "packages/services/service-realtime/scripts/i18n-extract.config.ts": 0, + "packages/services/service-storage/scripts/i18n-extract.config.ts": 0 }