From d5941644c397d7f7d5c6a7c9cb789ead736588f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:47:19 +0000 Subject: [PATCH 1/7] fix(i18n): read the provenance companion at serving time in seven bundle sets Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- .../src/translations/index.ts | 43 ++++++++++++++++--- .../plugin-audit/src/translations/index.ts | 43 ++++++++++++++++--- .../plugin-security/src/translations/index.ts | 43 ++++++++++++++++--- .../plugin-sharing/src/translations/index.ts | 43 ++++++++++++++++--- .../src/translations/index.ts | 43 ++++++++++++++++--- .../src/translations/index.ts | 43 ++++++++++++++++--- .../service-storage/src/translations/index.ts | 43 ++++++++++++++++--- 7 files changed, 266 insertions(+), 35 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/translations/index.ts b/packages/plugins/plugin-approvals/src/translations/index.ts index d448d5f631..3bf496d33b 100644 --- a/packages/plugins/plugin-approvals/src/translations/index.ts +++ b/packages/plugins/plugin-approvals/src/translations/index.ts @@ -9,15 +9,48 @@ * `scripts/i18n-extract.config.ts`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, #11671). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects }; export const ApprovalsTranslations: TranslationBundle = { - en: { objects: enObjects }, - 'zh-CN': { objects: zhCNObjects }, - 'ja-JP': { objects: jaJPObjects }, - 'es-ES': { objects: esESObjects }, + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), }; diff --git a/packages/plugins/plugin-audit/src/translations/index.ts b/packages/plugins/plugin-audit/src/translations/index.ts index 0d7f606c31..609a0ba848 100644 --- a/packages/plugins/plugin-audit/src/translations/index.ts +++ b/packages/plugins/plugin-audit/src/translations/index.ts @@ -10,16 +10,49 @@ * `scripts/i18n-extract.config.ts`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; import { enMessages, zhCNMessages, jaJPMessages, esESMessages } from './messages.js'; +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, #11671). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects, messages: enMessages }; + export const AuditTranslations: TranslationBundle = { - en: { objects: enObjects, messages: enMessages }, - 'zh-CN': { objects: zhCNObjects, messages: zhCNMessages }, - 'ja-JP': { objects: jaJPObjects, messages: jaJPMessages }, - 'es-ES': { objects: esESObjects, messages: esESMessages }, + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects, messages: zhCNMessages }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects, messages: jaJPMessages }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects, messages: esESMessages }, enSource, undefined, esESGeneratedSourceHashes), }; diff --git a/packages/plugins/plugin-security/src/translations/index.ts b/packages/plugins/plugin-security/src/translations/index.ts index bbefc1b35c..62faa80b6c 100644 --- a/packages/plugins/plugin-security/src/translations/index.ts +++ b/packages/plugins/plugin-security/src/translations/index.ts @@ -9,15 +9,48 @@ * `scripts/i18n-extract.config.ts`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, #11671). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects }; export const SecurityTranslations: TranslationBundle = { - en: { objects: enObjects }, - 'zh-CN': { objects: zhCNObjects }, - 'ja-JP': { objects: jaJPObjects }, - 'es-ES': { objects: esESObjects }, + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), }; diff --git a/packages/plugins/plugin-sharing/src/translations/index.ts b/packages/plugins/plugin-sharing/src/translations/index.ts index 7aea2a4650..56f2885739 100644 --- a/packages/plugins/plugin-sharing/src/translations/index.ts +++ b/packages/plugins/plugin-sharing/src/translations/index.ts @@ -9,15 +9,48 @@ * `scripts/i18n-extract.config.ts`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, #11671). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects }; export const SharingTranslations: TranslationBundle = { - en: { objects: enObjects }, - 'zh-CN': { objects: zhCNObjects }, - 'ja-JP': { objects: jaJPObjects }, - 'es-ES': { objects: esESObjects }, + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), }; diff --git a/packages/services/service-messaging/src/translations/index.ts b/packages/services/service-messaging/src/translations/index.ts index 5f4e71d7fa..2356ad01c8 100644 --- a/packages/services/service-messaging/src/translations/index.ts +++ b/packages/services/service-messaging/src/translations/index.ts @@ -12,15 +12,48 @@ * against `scripts/i18n-extract.config.ts`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, #11671). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects }; export const MessagingTranslations: TranslationBundle = { - en: { objects: enObjects }, - 'zh-CN': { objects: zhCNObjects }, - 'ja-JP': { objects: jaJPObjects }, - 'es-ES': { objects: esESObjects }, + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), }; diff --git a/packages/services/service-realtime/src/translations/index.ts b/packages/services/service-realtime/src/translations/index.ts index 7e4624717e..19744f2a54 100644 --- a/packages/services/service-realtime/src/translations/index.ts +++ b/packages/services/service-realtime/src/translations/index.ts @@ -10,15 +10,48 @@ * `scripts/i18n-extract.config.ts`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, #11671). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects }; export const RealtimeTranslations: TranslationBundle = { - en: { objects: enObjects }, - 'zh-CN': { objects: zhCNObjects }, - 'ja-JP': { objects: jaJPObjects }, - 'es-ES': { objects: esESObjects }, + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), }; diff --git a/packages/services/service-storage/src/translations/index.ts b/packages/services/service-storage/src/translations/index.ts index cc225b825e..9bedbe31b2 100644 --- a/packages/services/service-storage/src/translations/index.ts +++ b/packages/services/service-storage/src/translations/index.ts @@ -12,15 +12,48 @@ * `scripts/i18n-extract.config.ts`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, #11671). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects }; export const StorageTranslations: TranslationBundle = { - en: { objects: enObjects }, - 'zh-CN': { objects: zhCNObjects }, - 'ja-JP': { objects: jaJPObjects }, - 'es-ES': { objects: esESObjects }, + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), }; From c83163f69c6d114f6d4fc8f67789999ad18af8b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:55:27 +0000 Subject: [PATCH 2/7] feat(i18n): fail the build when a committed provenance companion is never served Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- .repro-12642/probe.ts | 45 ++++ .repro-12642/run.sh | 62 ++++++ scripts/check-i18n-stale-fill.mjs | 355 +++++++++++++++++++++++++++++- 3 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 .repro-12642/probe.ts create mode 100755 .repro-12642/run.sh diff --git a/.repro-12642/probe.ts b/.repro-12642/probe.ts new file mode 100644 index 0000000000..399621f892 --- /dev/null +++ b/.repro-12642/probe.ts @@ -0,0 +1,45 @@ +// Serving-seam reproduction probe. +// +// Resolves every module by RELATIVE SOURCE PATH on purpose: each one imports +// only `type`-level symbols from @objectstack/spec, so nothing here reads a +// dist/ and the probe measures exactly the committed text. Declared, not +// implied — no ablation-of-a-built-artifact is involved. +import { + findStaleFills, + collectGeneratedLeaves, + hashSource, +} from '../packages/platform-objects/src/apps/translations/source-hash.js'; + +// --- the card's measured case: plugin-sharing, es-ES, one recorded leaf ----- +import { SharingTranslations } from '../packages/plugins/plugin-sharing/src/translations/index.js'; +import { enObjects } from '../packages/plugins/plugin-sharing/src/translations/en.objects.generated.js'; +import { esESObjects } from '../packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.js'; +import { esESGeneratedSourceHashes } from '../packages/plugins/plugin-sharing/src/translations/es-ES.source-hashes.generated.js'; + +// --- positive control: platform-objects, where the seam was already wired --- +import { SetupAppTranslations } from '../packages/platform-objects/src/apps/translations/setup.translation.js'; +import { esES as poEsESRaw } from '../packages/platform-objects/src/apps/translations/es-ES.js'; + +const PATH = 'objects.sys_share_link.fields.token.label'; +const CONTROL = 'objects.sys_account._actions.link_social.params.provider.options.apple'; + +const read = (d: any, p: string) => p.split('.').reduce((n: any, k) => (n == null ? undefined : n[k]), d); + +// The bundle shape `origin/main` served for this set: the raw generated modules, +// assembled with no consultation of the companion sitting beside them. +const UNWIRED = { objects: esESObjects } as any; +const enSource = { objects: enObjects } as any; + +console.log('================ plugin-sharing :: ' + PATH + ' ================'); +console.log(' current source (en) =', JSON.stringify(collectGeneratedLeaves(enSource).get(PATH))); +console.log(' recorded digest (es-ES) =', esESGeneratedSourceHashes[PATH]); +console.log(' hash(current source) =', hashSource(String(collectGeneratedLeaves(enSource).get(PATH)))); +const stale = findStaleFills(UNWIRED, enSource, esESGeneratedSourceHashes as any); +console.log(' findStaleFills(es-ES) =', stale.length, 'stale', stale.map((s) => s.path).join(',')); +console.log(' BEFORE (origin/main shape, companion never read) serves =', JSON.stringify(read(UNWIRED, PATH))); +console.log(' AFTER (this branch, companion read at serving time) =', JSON.stringify(read((SharingTranslations as any)['es-ES'], PATH))); + +console.log('\n================ POSITIVE CONTROL: platform-objects (seam already wired on main) ================'); +console.log(' raw es-ES module serves =', JSON.stringify(read(poEsESRaw, CONTROL))); +console.log(' SetupAppTranslations es-ES =', JSON.stringify(read((SetupAppTranslations as any)['es-ES'], CONTROL))); +console.log(' probe can observe substitution =', read(poEsESRaw, CONTROL) !== read((SetupAppTranslations as any)['es-ES'], CONTROL) ? 'YES' : 'no'); diff --git a/.repro-12642/run.sh b/.repro-12642/run.sh new file mode 100755 index 0000000000..502efbd04a --- /dev/null +++ b/.repro-12642/run.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -uo pipefail +REPO_ROOT="$(git -C /home/user/objectstack-issue-12642 rev-parse --show-toplevel)" +SHARING_EN="$REPO_ROOT/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts" +PO_EN="$REPO_ROOT/packages/platform-objects/src/apps/translations/en.objects.generated.ts" + +restore() { + git -C "$REPO_ROOT" checkout HEAD -- "$SHARING_EN" "$PO_EN" 2>/dev/null || true +} +trap restore EXIT INT TERM + +hash_head() { git -C "$REPO_ROOT" rev-parse "HEAD:${1#$REPO_ROOT/}"; } + +for f in "$SHARING_EN" "$PO_EN"; do + h="$(hash_head "$f")" + if [ -z "$h" ]; then echo "FATAL: empty HEAD blob hash for $f" >&2; exit 1; fi + echo "HEAD blob $f = $h" +done + +echo +echo "##### ARM 0 — tree as committed (0 stale by construction) #####" +( cd "$REPO_ROOT" && npx tsx .repro-12642/probe.ts ) + +echo +echo "##### MUTATION — revise the SOURCE string behind each probed leaf #####" +# plugin-sharing: objects.sys_share_link.fields.token.label "Token" -> "Share token" +before_old=$(grep -c 'label: "Token"' "$SHARING_EN") +perl -0pi -e 's/(token: \{\n )label: "Token"/$1label: "Share token"/' "$SHARING_EN" +after_old=$(grep -c 'label: "Token"' "$SHARING_EN") +after_new=$(grep -c 'label: "Share token"' "$SHARING_EN") +echo " plugin-sharing en: 'label: \"Token\"' ${before_old} -> ${after_old} ; 'label: \"Share token\"' -> ${after_new}" +if [ "$after_new" -lt 1 ] || [ "$after_old" -ge "$before_old" ]; then + echo " FATAL: mutation was a NO-OP on disk — reading aborted, nothing measured." >&2; exit 1 +fi + +# platform-objects control: ...provider.options.apple "Apple" -> "Apple ID" +po_before=$(grep -c 'apple: "Apple"' "$PO_EN") +perl -0pi -e 's/apple: "Apple"/apple: "Apple ID"/' "$PO_EN" +po_after_old=$(grep -c 'apple: "Apple"' "$PO_EN") +po_after_new=$(grep -c 'apple: "Apple ID"' "$PO_EN") +echo " platform-objects en: 'apple: \"Apple\"' ${po_before} -> ${po_after_old} ; 'apple: \"Apple ID\"' -> ${po_after_new}" +if [ "$po_after_new" -lt 1 ]; then + echo " FATAL: control mutation was a NO-OP on disk — nothing measured." >&2; exit 1 +fi + +echo +echo "##### ARM 1 — source revised underneath the recorded leaves #####" +( cd "$REPO_ROOT" && npx tsx .repro-12642/probe.ts ) + +echo +echo "##### RESTORE #####" +restore +trap - EXIT INT TERM +for f in "$SHARING_EN" "$PO_EN"; do + cur="$(git -C "$REPO_ROOT" hash-object "$f")" + head="$(hash_head "$f")" + if [ -z "$cur" ] || [ -z "$head" ]; then echo "FATAL: empty hash on restore check for $f" >&2; exit 1; fi + if [ "$cur" != "$head" ]; then echo "FATAL: restore did not return $f to HEAD ($cur != $head)" >&2; exit 1; fi + echo " restored OK, byte-identical to HEAD: $f" +done +git -C "$REPO_ROOT" diff HEAD --stat -- "$SHARING_EN" "$PO_EN" | sed 's/^/ git diff HEAD: /' +echo " (empty diff above == restored)" diff --git a/scripts/check-i18n-stale-fill.mjs b/scripts/check-i18n-stale-fill.mjs index 48360c196b..4c8f4f1f6a 100644 --- a/scripts/check-i18n-stale-fill.mjs +++ b/scripts/check-i18n-stale-fill.mjs @@ -1,7 +1,28 @@ #!/usr/bin/env node // check-i18n-stale-fill — the `pnpm check:i18n-stale-fill` gate. // -// ONE verdict: a translated leaf that is a COPY OF A PREVIOUS SOURCE REVISION. +// TWO verdicts, kept distinct — both about the same population, the committed +// translation bundles and the provenance companions beside them: +// +// 1. STALE FILL a translated leaf that is a COPY OF A PREVIOUS +// SOURCE REVISION, detected from cross-locale +// agreement alone. The original verdict; everything +// down to "## The ledger" below is about it. +// 2. UNSERVED PROVENANCE a bundle set that COMMITS a +// `.source-hashes.generated.ts` companion and +// never consults it at serving time, so it records the +// drift and goes on serving the superseded draft. See +// the section at the definition of +// `UNSERVED_PROVENANCE` for why recording without +// serving is worse than not recording at all. +// +// Verdict 2 exists because verdict 1 is blind by construction in exactly the +// case provenance was introduced to cover: a leaf revised in ONE locale has no +// second witness, so condition 1 can never fire on it. Measured on the tree the +// day the recording rollout landed: provenance RECORDED in 9 of 9 bundle sets +// and READ at serving time in 1. Every gate green, and es-ES serving a +// superseded English draft. Nothing in either half's file surface said so — +// which is why the check is mechanical now rather than a sentence in a header. // // ## The hole (#11671) // @@ -101,8 +122,10 @@ // baseline entry carries a reason, so the ledger is a worklist rather than a // silencer. // -// node scripts/check-i18n-stale-fill.mjs # gate -// node scripts/check-i18n-stale-fill.mjs --update # re-baseline from the tree +// node scripts/check-i18n-stale-fill.mjs # gate (both verdicts) +// node scripts/check-i18n-stale-fill.mjs --update # re-baseline VERDICT 1 from the tree +// # (verdict 2 has no ratchet — see +// # UNSERVED_PROVENANCE) // node scripts/check-i18n-stale-fill.mjs --self-test # prove every rule can go red // // Needs NO workspace build: it reads the committed bundles as text, so unlike @@ -271,6 +294,113 @@ export function ratchet(found, baselined) { const NEW_REASON = 'unclassified — triage this: real drift (re-translate the leaf) or a benign coincidence'; +// --------------------------------------------------------------------------- +// Verdict 2 — UNSERVED PROVENANCE +// +// ## Recording without serving is worse than not recording at all +// +// A committed companion is read by a human as evidence that the mechanism is +// ON for that set. Maintainer ruling #12069 Option A landed provenance as two +// halves — `os i18n extract --source-hashes` RECORDS, and `withSourceFallback` +// SUBSTITUTES at serving time — and the rollout of the first half to every set +// left the second where it was. The tree then said `--source-hashes` in nine +// extract configs, 27 committed companions and a changeset announcing the +// rollout, while eight of the nine sets assembled their `TranslationBundle` +// from the raw generated modules and never looked at the companion sitting +// beside them. The natural reading of all of that — "a stale generated leaf now +// serves the current source everywhere" — was false, and no gate disagreed. +// +// ## Why this verdict is a SOURCE SCAN and what that costs +// +// The behavioural alternatives were measured and are vacuous today. Every +// record is written only for a leaf that IS a byte copy of the CURRENT source +// (see the extract configs' docstrings), so the tree arrives 0-stale by +// construction: comparing served bytes against source bytes is green whether or +// not the seam is wired. Object identity is no better — `withSourceFallback` +// returns its input by reference when nothing is stale, deliberately, so a +// wired bundle and an unwired one are the same object. A gate that can only be +// observed green is indistinguishable from a gate that matches nothing (#4690), +// so the structural question — "does the serving code consult this table?" — is +// the one that can actually be answered on a clean tree. +// +// The price of a source scan is that it sees only the spellings it knows, so +// they are published here rather than left inside the implementation. A +// companion counts as SERVED when some non-generated, non-test `.ts` file under +// the same package's `src/` passes the companion's own exported identifier as +// an ARGUMENT to a `withSourceFallback(...)` call: +// +// import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; +// 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), +// +// Naming the identifier is not enough and neither is calling the seam: the +// measured near-miss is a set that wires two locales and forgets the third, and +// an import-only or call-only test reads that as fully served. Both halves are +// required PER COMPANION, which is per locale. +// --------------------------------------------------------------------------- + +/** The seam that turns a provenance record into a served string. */ +const PROVENANCE_SEAM = 'withSourceFallback'; + +/** + * Bundle sets that commit a provenance companion and deliberately do NOT + * consult it, keyed by the extract config's own `--out=` directory. + * + * Hand-maintained and shrink-only, with no `--update` that can grow it: an + * entry here is a decision someone made and wrote down, not a measurement to + * be re-taken. The gate fails BOTH ways — a set that starts serving its + * companion must delete its entry in the same PR, so the ledger cannot outlive + * the hole it documents. + */ +const UNSERVED_PROVENANCE = { + 'packages/plugins/plugin-webhooks/src/translations': + '@objectstack/plugin-webhooks does not depend on @objectstack/platform-objects, where ' + + '`withSourceFallback` lives — its dependencies are @objectstack/core, ' + + '@objectstack/service-messaging and @objectstack/spec. Wiring it needs either a new ' + + 'package edge or the mechanism relocated to a package all nine sets reach, and the ' + + 'nine share no runtime dependency but @objectstack/spec. That is an architecture ' + + 'call, not a mechanical follow-up, so it is open rather than forced. Recorded ' + + '2026-08-27, when the other eight sets were wired: this set records provenance for 20 ' + + 'leaves across three locales and serves the superseded draft when a source moves under ' + + 'one of them. Delete this entry in the PR that wires it.', +}; + +/** The identifier a provenance companion exports, read from the file itself. */ +export function provenanceExportName(source) { + const m = source.match(/export const ([A-Za-z_$][A-Za-z0-9_$]*)/); + return m ? m[1] : undefined; +} + +/** + * The argument text of every `withSourceFallback(...)` call in a file, matched + * with a balanced-paren scan rather than a regex so a nested call or a + * parenthesised argument cannot truncate the match and read as "not served". + */ +export function provenanceCallArguments(sourceText) { + const out = []; + const re = new RegExp(`\\b${PROVENANCE_SEAM}\\s*\\(`, 'g'); + let m; + while ((m = re.exec(sourceText)) !== null) { + let depth = 1; + let i = re.lastIndex; + while (i < sourceText.length && depth > 0) { + const c = sourceText[i]; + if (c === '(') depth += 1; + else if (c === ')') depth -= 1; + i += 1; + } + out.push(sourceText.slice(re.lastIndex, i - 1)); + re.lastIndex = i; + } + return out; +} + +/** Is `ident` passed as an argument to a seam call in this file? */ +export function servedByCallSite(sourceText, ident) { + if (!ident) return false; + const named = new RegExp(`(^|[^A-Za-z0-9_$])${ident}([^A-Za-z0-9_$]|$)`); + return provenanceCallArguments(sourceText).some((args) => named.test(args)); +} + // --------------------------------------------------------------------------- // Population // --------------------------------------------------------------------------- @@ -303,6 +433,65 @@ function discoverBundleSets() { return sets; } +/** The package root owning an out-dir: the nearest ancestor with a manifest. */ +function packageRootOf(outDir) { + let dir = outDir; + while (dir && dir !== '.' && dir !== PACKAGES_DIR) { + if (existsSync(at(join(dir, 'package.json')))) return dir; + dir = dirname(dir); + } + return undefined; +} + +/** + * The `.ts` files that could serve a bundle: everything under the package's + * `src/` that is neither generated nor a test. Generated files are excluded + * because a companion importing itself would otherwise read as served, and + * tests because a call in a test proves the FUNCTION works, never that the + * shipped bundle goes through it — which is the whole distinction this verdict + * draws. + */ +function servingSourcesOf(pkgRoot) { + const src = at(join(pkgRoot, 'src')); + if (!existsSync(src)) return []; + return readdirSync(src, { withFileTypes: true, recursive: true }) + .filter((e) => e.isFile() && e.name.endsWith('.ts')) + .filter((e) => !e.name.endsWith('.generated.ts') && !/\.(test|spec)\.ts$/.test(e.name)) + .map((e) => join(e.parentPath ?? e.path, e.name)); +} + +/** + * Every committed provenance companion, and whether the package that owns it + * consults it at serving time. Discovered through the extract configs' own + * documented `--out=`, the same seam as `discoverBundleSets` — a set that lands + * tomorrow is judged tomorrow, with no manifest to forget to update. + */ +function discoverProvenanceServing() { + const rows = []; + for (const config of findExtractConfigs(at(PACKAGES_DIR), PACKAGES_DIR)) { + const flags = flagsFromDocstring(config.abs); + const out = flags.find((f) => f.startsWith('--out='))?.slice('--out='.length); + if (!out || !existsSync(at(out))) continue; + const companions = readdirSync(at(out)).filter((f) => f.endsWith(PROVENANCE_KIND)).sort(); + if (!companions.length) continue; + const pkgRoot = packageRootOf(out); + if (!pkgRoot) { + console.error(`\ncheck-i18n-stale-fill: no package.json above ${out} — cannot judge its provenance serving.\n`); + process.exit(1); + } + const sources = servingSourcesOf(pkgRoot).map((f) => readFileSync(f, 'utf8')); + for (const file of companions) { + const ident = provenanceExportName(readFileSync(at(`${out}/${file}`), 'utf8')); + if (!ident) { + console.error(`\ncheck-i18n-stale-fill: ${out}/${file} declares no \`export const\` — cannot judge whether it is served.\n`); + process.exit(1); + } + rows.push({ out, file, ident, served: sources.some((text) => servedByCallSite(text, ident)) }); + } + } + return rows; +} + function selfTest() { let failures = 0; const expect = (what, ok) => { @@ -423,6 +612,82 @@ function selfTest() { isTranslationBundleKind('objects.generated.ts') && isTranslationBundleKind('metadata-forms.generated.ts'), ); + // ---- Verdict 2 — UNSERVED PROVENANCE ----------------------------------- + // + // The three shapes below are the ones actually measured on this repo: the + // wired barrel, the barrel that assembles from raw modules (eight sets on the + // day provenance was rolled out), and the barrel that wires two locales and + // forgets the third. A rule that cannot separate the last two would have read + // a half-wired set as served. + const WIRED = [ + "import { withSourceFallback } from '@objectstack/platform-objects/apps';", + "import { esESObjects } from './es-ES.objects.generated.js';", + "import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js';", + 'const enSource: TranslationData = { objects: enObjects };', + "export const XTranslations: TranslationBundle = {", + ' en: enSource,', + " 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes),", + '};', + ].join('\n'); + const RAW = [ + "import { esESObjects } from './es-ES.objects.generated.js';", + "export const XTranslations: TranslationBundle = {", + ' en: { objects: enObjects },', + " 'es-ES': { objects: esESObjects },", + '};', + ].join('\n'); + + expect( + 'a barrel that passes the companion to withSourceFallback IS served', + servedByCallSite(WIRED, 'esESGeneratedSourceHashes') === true, + ); + expect( + "the eight sets' measured shape — raw generated modules, companion never named — is NOT served", + servedByCallSite(RAW, 'esESGeneratedSourceHashes') === false, + ); + expect( + 'IMPORTING the companion without passing it to the seam is NOT served (the near-miss)', + servedByCallSite( + WIRED.replace(', esESGeneratedSourceHashes)', ')'), + 'esESGeneratedSourceHashes', + ) === false, + ); + expect( + 'a set that wires zh-CN and forgets es-ES is served for zh-CN and NOT for es-ES', + servedByCallSite(WIRED.replace(/esES/g, 'zhCN'), 'zhCNGeneratedSourceHashes') === true && + servedByCallSite(WIRED.replace(/esES/g, 'zhCN'), 'esESGeneratedSourceHashes') === false, + ); + expect( + 'a nested call in an earlier argument does not truncate the scan (balanced parens)', + servedByCallSite( + "withSourceFallback(merge(a, b), enSource, undefined, esESGeneratedSourceHashes);", + 'esESGeneratedSourceHashes', + ) === true, + ); + expect( + 'an identifier that merely CONTAINS the name is not a match', + servedByCallSite( + 'withSourceFallback(x, y, undefined, esESGeneratedSourceHashesLegacy);', + 'esESGeneratedSourceHashes', + ) === false, + ); + expect( + 'provenanceCallArguments finds every call, not just the first', + provenanceCallArguments( + "a: withSourceFallback(p, q, undefined, zhCNGen),\nb: withSourceFallback(r, s, undefined, esESGen),", + ).length === 2, + ); + expect( + 'provenanceExportName reads the companion’s own exported identifier', + provenanceExportName( + 'export const esESGeneratedSourceHashes: Readonly> = {\n "a.b": "0011223344556677",\n};', + ) === 'esESGeneratedSourceHashes', + ); + expect( + 'provenanceExportName returns undefined for a file that exports nothing (the gate REFUSES rather than passing)', + provenanceExportName('// just a comment') === undefined, + ); + console.log(failures === 0 ? '\ncheck-i18n-stale-fill: self-test OK\n' : `\ncheck-i18n-stale-fill: self-test FAILED (${failures})\n`); process.exit(failures === 0 ? 0 : 1); } @@ -437,7 +702,91 @@ function selfTest() { // `check:entry-guard` is there to catch. // --------------------------------------------------------------------------- +/** + * Verdict 2. Runs FIRST and exits on failure: an unserved companion means the + * bundle this gate is about to judge is not the bundle the product serves, so + * reporting stale fills over it would be measuring the wrong tree. + */ +function judgeProvenanceServing() { + const rows = discoverProvenanceServing(); + + // #4690 / #10907, the same refusal the stale-fill population makes: zero is a + // broken scan, not a repo that records no provenance. + if (rows.length === 0) { + console.error( + `\ncheck-i18n-stale-fill: REFUSING TO JUDGE — no \`.${PROVENANCE_KIND}\` companion was found under ${PACKAGES_DIR}/.\n\n` + + ` This gate reads ${PACKAGES_DIR}/ from its own location (${ROOT}), so an empty\n` + + ` population is a broken scan, not a tree without provenance. Nothing was checked.\n`, + ); + process.exit(1); + } + + const unservedByOut = new Map(); + for (const row of rows) { + if (row.served) continue; + if (!unservedByOut.has(row.out)) unservedByOut.set(row.out, []); + unservedByOut.get(row.out).push(row); + } + + const declared = new Set(Object.keys(UNSERVED_PROVENANCE)); + const undeclared = [...unservedByOut.keys()].filter((out) => !declared.has(out)).sort(); + const repaired = [...declared].filter((out) => !unservedByOut.has(out)).sort(); + + console.log( + `check-i18n-stale-fill: ${rows.length} provenance companion(s), ` + + `${rows.filter((r) => r.served).length} served at serving time, ` + + `${unservedByOut.size} bundle set(s) unserved (${declared.size} declared).`, + ); + + if (undeclared.length) { + console.error( + `\ncheck-i18n-stale-fill: UNSERVED PROVENANCE — ${undeclared.length} bundle set(s) record provenance and never read it\n\n` + + `A committed \`.${PROVENANCE_KIND}\` says a leaf is still a byte copy of a\n` + + `RECORDED source revision. Recording alone changes nothing anyone sees: when the source\n` + + `moves, this set keeps serving the superseded draft, \`check:i18n\` stays OK (the key sets\n` + + `still match), \`check:i18n-coverage\` counts the leaf translated, and this gate's own\n` + + `stale-fill verdict cannot testify unless a SECOND locale happens to hold the same bytes.\n`, + ); + for (const out of undeclared) { + console.error(` • ${out}`); + for (const row of unservedByOut.get(out)) { + console.error(` ${row.file} — \`${row.ident}\` is passed to no ${PROVENANCE_SEAM}() call in this package`); + } + } + console.error( + `\nFix at the bundle set's serving barrel — pass the companion as the FOURTH argument,\n` + + `the shape \`@objectstack/platform-objects\`'s own \`metadata-translations/index.ts\` uses:\n\n` + + ` import { withSourceFallback } from '@objectstack/platform-objects/apps';\n` + + ` import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js';\n\n` + + ` const enSource: TranslationData = { objects: enObjects };\n` + + ` export const XTranslations: TranslationBundle = {\n` + + ` en: enSource,\n` + + ` 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes),\n` + + ` };\n\n` + + `The third argument stays \`undefined\` for a fully generated set: it judges the\n` + + `HAND-AUTHORED sections. ⛔ Deleting the companion to quiet this is not a fix — it\n` + + `discards the only evidence that tells a stale fill from a real translation. If the\n` + + `set genuinely cannot reach the seam, record it in UNSERVED_PROVENANCE with the reason.\n`, + ); + process.exit(1); + } + + if (repaired.length) { + console.error( + `\ncheck-i18n-stale-fill: ${repaired.length} UNSERVED_PROVENANCE entry/entries now serve their companion (improvement!)\n`, + ); + for (const out of repaired) console.error(` • ${out}`); + console.error( + `\nDelete each entry above from UNSERVED_PROVENANCE in ${'scripts/check-i18n-stale-fill.mjs'} —\n` + + `a ledger entry that outlives its hole reads as a hole that is still open.\n`, + ); + process.exit(1); + } +} + function main() { +judgeProvenanceServing(); + const sets = discoverBundleSets(); // #4690 / #10907: zero is a broken scan, not a repo with nothing to translate. From 088e44a2af19e37f978dd021e756ab53d2401942 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:03:30 +0000 Subject: [PATCH 3/7] test(plugin-sharing): pin that the barrel serves the current source when it moves Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- .repro-12642/probe.ts | 45 -------- .repro-12642/run.sh | 62 ----------- .../src/translations/serving-seam.test.ts | 103 ++++++++++++++++++ 3 files changed, 103 insertions(+), 107 deletions(-) delete mode 100644 .repro-12642/probe.ts delete mode 100755 .repro-12642/run.sh create mode 100644 packages/plugins/plugin-sharing/src/translations/serving-seam.test.ts diff --git a/.repro-12642/probe.ts b/.repro-12642/probe.ts deleted file mode 100644 index 399621f892..0000000000 --- a/.repro-12642/probe.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Serving-seam reproduction probe. -// -// Resolves every module by RELATIVE SOURCE PATH on purpose: each one imports -// only `type`-level symbols from @objectstack/spec, so nothing here reads a -// dist/ and the probe measures exactly the committed text. Declared, not -// implied — no ablation-of-a-built-artifact is involved. -import { - findStaleFills, - collectGeneratedLeaves, - hashSource, -} from '../packages/platform-objects/src/apps/translations/source-hash.js'; - -// --- the card's measured case: plugin-sharing, es-ES, one recorded leaf ----- -import { SharingTranslations } from '../packages/plugins/plugin-sharing/src/translations/index.js'; -import { enObjects } from '../packages/plugins/plugin-sharing/src/translations/en.objects.generated.js'; -import { esESObjects } from '../packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.js'; -import { esESGeneratedSourceHashes } from '../packages/plugins/plugin-sharing/src/translations/es-ES.source-hashes.generated.js'; - -// --- positive control: platform-objects, where the seam was already wired --- -import { SetupAppTranslations } from '../packages/platform-objects/src/apps/translations/setup.translation.js'; -import { esES as poEsESRaw } from '../packages/platform-objects/src/apps/translations/es-ES.js'; - -const PATH = 'objects.sys_share_link.fields.token.label'; -const CONTROL = 'objects.sys_account._actions.link_social.params.provider.options.apple'; - -const read = (d: any, p: string) => p.split('.').reduce((n: any, k) => (n == null ? undefined : n[k]), d); - -// The bundle shape `origin/main` served for this set: the raw generated modules, -// assembled with no consultation of the companion sitting beside them. -const UNWIRED = { objects: esESObjects } as any; -const enSource = { objects: enObjects } as any; - -console.log('================ plugin-sharing :: ' + PATH + ' ================'); -console.log(' current source (en) =', JSON.stringify(collectGeneratedLeaves(enSource).get(PATH))); -console.log(' recorded digest (es-ES) =', esESGeneratedSourceHashes[PATH]); -console.log(' hash(current source) =', hashSource(String(collectGeneratedLeaves(enSource).get(PATH)))); -const stale = findStaleFills(UNWIRED, enSource, esESGeneratedSourceHashes as any); -console.log(' findStaleFills(es-ES) =', stale.length, 'stale', stale.map((s) => s.path).join(',')); -console.log(' BEFORE (origin/main shape, companion never read) serves =', JSON.stringify(read(UNWIRED, PATH))); -console.log(' AFTER (this branch, companion read at serving time) =', JSON.stringify(read((SharingTranslations as any)['es-ES'], PATH))); - -console.log('\n================ POSITIVE CONTROL: platform-objects (seam already wired on main) ================'); -console.log(' raw es-ES module serves =', JSON.stringify(read(poEsESRaw, CONTROL))); -console.log(' SetupAppTranslations es-ES =', JSON.stringify(read((SetupAppTranslations as any)['es-ES'], CONTROL))); -console.log(' probe can observe substitution =', read(poEsESRaw, CONTROL) !== read((SetupAppTranslations as any)['es-ES'], CONTROL) ? 'YES' : 'no'); diff --git a/.repro-12642/run.sh b/.repro-12642/run.sh deleted file mode 100755 index 502efbd04a..0000000000 --- a/.repro-12642/run.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -set -uo pipefail -REPO_ROOT="$(git -C /home/user/objectstack-issue-12642 rev-parse --show-toplevel)" -SHARING_EN="$REPO_ROOT/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts" -PO_EN="$REPO_ROOT/packages/platform-objects/src/apps/translations/en.objects.generated.ts" - -restore() { - git -C "$REPO_ROOT" checkout HEAD -- "$SHARING_EN" "$PO_EN" 2>/dev/null || true -} -trap restore EXIT INT TERM - -hash_head() { git -C "$REPO_ROOT" rev-parse "HEAD:${1#$REPO_ROOT/}"; } - -for f in "$SHARING_EN" "$PO_EN"; do - h="$(hash_head "$f")" - if [ -z "$h" ]; then echo "FATAL: empty HEAD blob hash for $f" >&2; exit 1; fi - echo "HEAD blob $f = $h" -done - -echo -echo "##### ARM 0 — tree as committed (0 stale by construction) #####" -( cd "$REPO_ROOT" && npx tsx .repro-12642/probe.ts ) - -echo -echo "##### MUTATION — revise the SOURCE string behind each probed leaf #####" -# plugin-sharing: objects.sys_share_link.fields.token.label "Token" -> "Share token" -before_old=$(grep -c 'label: "Token"' "$SHARING_EN") -perl -0pi -e 's/(token: \{\n )label: "Token"/$1label: "Share token"/' "$SHARING_EN" -after_old=$(grep -c 'label: "Token"' "$SHARING_EN") -after_new=$(grep -c 'label: "Share token"' "$SHARING_EN") -echo " plugin-sharing en: 'label: \"Token\"' ${before_old} -> ${after_old} ; 'label: \"Share token\"' -> ${after_new}" -if [ "$after_new" -lt 1 ] || [ "$after_old" -ge "$before_old" ]; then - echo " FATAL: mutation was a NO-OP on disk — reading aborted, nothing measured." >&2; exit 1 -fi - -# platform-objects control: ...provider.options.apple "Apple" -> "Apple ID" -po_before=$(grep -c 'apple: "Apple"' "$PO_EN") -perl -0pi -e 's/apple: "Apple"/apple: "Apple ID"/' "$PO_EN" -po_after_old=$(grep -c 'apple: "Apple"' "$PO_EN") -po_after_new=$(grep -c 'apple: "Apple ID"' "$PO_EN") -echo " platform-objects en: 'apple: \"Apple\"' ${po_before} -> ${po_after_old} ; 'apple: \"Apple ID\"' -> ${po_after_new}" -if [ "$po_after_new" -lt 1 ]; then - echo " FATAL: control mutation was a NO-OP on disk — nothing measured." >&2; exit 1 -fi - -echo -echo "##### ARM 1 — source revised underneath the recorded leaves #####" -( cd "$REPO_ROOT" && npx tsx .repro-12642/probe.ts ) - -echo -echo "##### RESTORE #####" -restore -trap - EXIT INT TERM -for f in "$SHARING_EN" "$PO_EN"; do - cur="$(git -C "$REPO_ROOT" hash-object "$f")" - head="$(hash_head "$f")" - if [ -z "$cur" ] || [ -z "$head" ]; then echo "FATAL: empty hash on restore check for $f" >&2; exit 1; fi - if [ "$cur" != "$head" ]; then echo "FATAL: restore did not return $f to HEAD ($cur != $head)" >&2; exit 1; fi - echo " restored OK, byte-identical to HEAD: $f" -done -git -C "$REPO_ROOT" diff HEAD --stat -- "$SHARING_EN" "$PO_EN" | sed 's/^/ git diff HEAD: /' -echo " (empty diff above == restored)" diff --git a/packages/plugins/plugin-sharing/src/translations/serving-seam.test.ts b/packages/plugins/plugin-sharing/src/translations/serving-seam.test.ts new file mode 100644 index 0000000000..3eb466dea7 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/translations/serving-seam.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The provenance companion is READ at serving time, not merely recorded. +// +// ## What this file pins, and what it deliberately does not +// +// `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` +// (maintainer ruling #12069 Option A, #11671) and `withSourceFallback` +// substitutes the current source for a leaf whose record disagrees with it. +// Those two halves landed apart: recording rolled out to all nine bundle sets +// and the reading half stayed in `@objectstack/platform-objects`. Eight sets +// then recorded the drift and went on serving the superseded draft, with every +// gate green. +// +// This set is where that gap was measured, on a leaf recorded in es-ES ALONE — +// which is why no gate could see it. `check:i18n` compares key sets and they +// still matched; `check:i18n-coverage` counts a present leaf as translated; and +// `check:i18n-stale-fill`'s cross-locale rule needs a SECOND locale holding the +// same stale bytes before it can testify. One locale, no second witness. +// +// The division of labour with the gate is worth stating, because neither half +// is sufficient alone: +// +// - THIS test proves the barrel BEHAVES — revise the source underneath the +// recorded leaf and `SharingTranslations` serves the current source. It +// drives the real `./index.js`, not a reconstruction of it. +// - `check:i18n-stale-fill`'s UNSERVED PROVENANCE verdict proves every OTHER +// bundle set's barrel is wired the same way, which no test in this package +// can see. +// +// ⚠️ A version of this test that asserted over the committed tree alone would +// be VACUOUS and would look identical to this one: a record is only ever +// written for a leaf that IS a byte copy of the CURRENT source, so the tree +// arrives 0-stale by construction and "served === source" holds whether or not +// the seam is wired. The source has to be MOVED for the two to differ, which is +// what the mock below does. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { withSourceFallback, findStaleFills } from '@objectstack/platform-objects/apps'; +import { enObjects } from './en.objects.generated.js'; +import { esESObjects } from './es-ES.objects.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** The leaf the gap was measured on: recorded in es-ES only. */ +const PATH = ['objects', 'sys_share_link', 'fields', 'token', 'label'] as const; +const REVISED = 'Share token'; + +const read = (data: unknown): unknown => + PATH.reduce((node, key) => (node == null ? undefined : node[key]), data); + +/** + * The generated `objects` map with the source string behind the recorded leaf + * revised — the state an `os i18n extract` run leaves behind after the label is + * edited: `en` is rewritten from the source every run and never merged (#8543), + * while the translated locales keep merge semantics and strand the previous + * text. + */ +function revisedObjects() { + const next = structuredClone(enObjects) as any; + next.sys_share_link.fields.token.label = REVISED; + return next; +} + +/** The same thing as a `TranslationData` — what the barrel passes as `source`. */ +const revisedSource = () => ({ objects: revisedObjects() }); + +describe('SharingTranslations — the provenance companion is read at serving time', () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock('./en.objects.generated.js'); + }); + + it('the leaf under test is recorded in es-ES and is a byte copy of the current source', () => { + const path = PATH.join('.'); + expect(esESGeneratedSourceHashes[path]).toBeTypeOf('string'); + expect(read({ objects: esESObjects })).toBe(read({ objects: enObjects })); + }); + + it('records the drift once the source moves — the evidence that already existed', () => { + const stale = findStaleFills({ objects: esESObjects }, revisedSource(), esESGeneratedSourceHashes); + expect(stale.map((s) => s.path)).toEqual([PATH.join('.')]); + }); + + it('SERVES the current source when the source moves under the recorded leaf', async () => { + vi.doMock('./en.objects.generated.js', () => ({ enObjects: revisedObjects() })); + const { SharingTranslations } = await import('./index.js'); + expect(read(SharingTranslations['es-ES'])).toBe(REVISED); + // The locales with no record for this path are legacy-trusted and untouched — + // recovery is per-locale, which is the half of ruling #8765 Option B that a + // blanket "fall back to source" would have destroyed. + expect(read(SharingTranslations['zh-CN'])).toBe('令牌'); + }); + + it('NEGATIVE CONTROL: the same bundle with no companion serves the superseded draft', () => { + const unserved = withSourceFallback({ objects: esESObjects }, revisedSource(), undefined, undefined); + expect(read(unserved)).toBe('Token'); + }); + + it('substitutes nothing while the source has not moved', async () => { + const { SharingTranslations } = await import('./index.js'); + expect(read(SharingTranslations['es-ES'])).toBe(read({ objects: enObjects })); + }); +}); From 3794f629a58de58e87f656f4769dc98c9f872309 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:05:05 +0000 Subject: [PATCH 4/7] chore(changeset): provenance served in seven more bundle sets Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- .../i18n-provenance-served-in-eight-sets.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .changeset/i18n-provenance-served-in-eight-sets.md diff --git a/.changeset/i18n-provenance-served-in-eight-sets.md b/.changeset/i18n-provenance-served-in-eight-sets.md new file mode 100644 index 0000000000..5bcdfe7555 --- /dev/null +++ b/.changeset/i18n-provenance-served-in-eight-sets.md @@ -0,0 +1,50 @@ +--- +"@objectstack/plugin-approvals": patch +"@objectstack/plugin-audit": patch +"@objectstack/plugin-security": patch +"@objectstack/plugin-sharing": patch +"@objectstack/service-messaging": patch +"@objectstack/service-realtime": patch +"@objectstack/service-storage": patch +--- + +fix(i18n): read the provenance companion at serving time, not only record it (#12642) + +Maintainer ruling #12069 Option A (#11671) landed translation provenance as +**two** halves: `os i18n extract --source-hashes` RECORDS which source revision +a generated leaf is still a byte copy of, and `withSourceFallback` READS those +records at serving time and substitutes the current source for a leaf whose +source has moved underneath it. The recording half was then rolled out to every +bundle set. The reading half was not — measured on `main`: provenance +**recorded in 9 of 9** bundle sets and **read at serving time in 1**. + +The other eight assembled their `TranslationBundle` straight from the raw +generated modules and never consulted the companion sitting beside them, so +they recorded the drift and went on serving the superseded draft. Nothing said +so: `check:i18n` compares key sets and they still matched, `check:i18n-coverage` +counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale +rule needs a SECOND locale holding the same stale bytes before it can testify. +The measured case had one locale and no second witness. + +Seven of the eight are wired here, in the shape +`@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses — +the committed `.source-hashes.generated.ts` passed as the fourth +argument, the third left `undefined` because these sets have no hand-authored +sections. `@objectstack/plugin-webhooks` is the eighth and is NOT wired: it does +not depend on `@objectstack/platform-objects`, where the seam lives, and the +nine sets share no runtime dependency but `@objectstack/spec`. Wiring it needs +either a new package edge or the mechanism relocated, which is an architecture +call rather than a mechanical follow-up. It is recorded in +`check:i18n-stale-fill`'s `UNSERVED_PROVENANCE` ledger with the reason, and that +gate's new **UNSERVED PROVENANCE** verdict now fails the build for any other set +that commits a companion and does not read it. + +**Graded `patch`, and the grade is the interesting part.** No API changes, no +new exported surface, and no key set moves — substitution was chosen over +deletion precisely so key-set claims stay put (ruling #8765 Option B). What +changes is which STRING a stale leaf serves. On this tree that is **zero +leaves**: a record is only ever written for a leaf that IS a byte copy of the +current source, so the companions arrive 0-stale by construction. The change is +in what happens the next time a source string moves — the reader sees the +English source rather than a superseded draft of it, which is the same +degradation an untranslated key already produces and not a new state. From 6676df7bc280eadf174883f8f103bde9dbe2618b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:11:15 +0000 Subject: [PATCH 5/7] chore(i18n): resolve the provenance seam to source in the two packages that newly import it Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- packages/plugins/plugin-approvals/tsconfig.json | 11 ++++++++++- packages/plugins/plugin-audit/tsconfig.json | 11 ++++++++++- packages/plugins/plugin-audit/vitest.config.ts | 9 +++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-approvals/tsconfig.json b/packages/plugins/plugin-approvals/tsconfig.json index 252ec046b4..e19acbea50 100644 --- a/packages/plugins/plugin-approvals/tsconfig.json +++ b/packages/plugins/plugin-approvals/tsconfig.json @@ -18,7 +18,16 @@ // (`pnpm check:type-source-resolution` — same fix `packages/rest` // records for `@objectstack/metadata-protocol`). "paths": { - "@objectstack/metadata-core": ["../../metadata-core/src/index.ts"] + "@objectstack/metadata-core": ["../../metadata-core/src/index.ts"], + // [#12642] Resolve the i18n provenance seam to SOURCE. This package's + // translation barrel passes its committed + // `.source-hashes.generated.ts` companions through + // `withSourceFallback`, whose home is `@objectstack/platform-objects/apps` + // — a NEW type import here, so without this rule the typecheck would be a + // verdict about that package's `dist/` build state + // (`pnpm check:type-source-resolution`). Anchored on the SUBPATH: the + // bare key would match by prefix and resolve `/apps` through a file. + "@objectstack/platform-objects/apps": ["../../platform-objects/src/apps/index.ts"] } }, "include": ["src/**/*"], diff --git a/packages/plugins/plugin-audit/tsconfig.json b/packages/plugins/plugin-audit/tsconfig.json index 40da004ccc..d67b2438a1 100644 --- a/packages/plugins/plugin-audit/tsconfig.json +++ b/packages/plugins/plugin-audit/tsconfig.json @@ -19,7 +19,16 @@ // (`pnpm check:type-source-resolution` — same fix `packages/rest` // records for `@objectstack/metadata-protocol`). "paths": { - "@objectstack/metadata-core": ["../../metadata-core/src/index.ts"] + "@objectstack/metadata-core": ["../../metadata-core/src/index.ts"], + // [#12642] Resolve the i18n provenance seam to SOURCE. This package's + // translation barrel passes its committed + // `.source-hashes.generated.ts` companions through + // `withSourceFallback`, whose home is `@objectstack/platform-objects/apps` + // — a NEW type import here, so without this rule the typecheck would be a + // verdict about that package's `dist/` build state + // (`pnpm check:type-source-resolution`). Anchored on the SUBPATH: the + // bare key would match by prefix and resolve `/apps` through a file. + "@objectstack/platform-objects/apps": ["../../platform-objects/src/apps/index.ts"] } }, "include": [ diff --git a/packages/plugins/plugin-audit/vitest.config.ts b/packages/plugins/plugin-audit/vitest.config.ts index c0e7e4169b..3313c9917d 100644 --- a/packages/plugins/plugin-audit/vitest.config.ts +++ b/packages/plugins/plugin-audit/vitest.config.ts @@ -35,6 +35,15 @@ export default defineConfig({ find: /^@objectstack\/platform-objects\/audit$/, replacement: path.resolve(__dirname, '../../platform-objects/src/audit/index.ts'), }, + // [#12642] The i18n provenance seam. This package's translation barrel + // passes its committed `.source-hashes.generated.ts` companions + // through `withSourceFallback`, whose home is this subpath — so without + // the alias the suite's verdict would be about `platform-objects/dist` + // build state rather than the checkout (`pnpm check:test-source-alias`). + { + find: /^@objectstack\/platform-objects\/apps$/, + replacement: path.resolve(__dirname, '../../platform-objects/src/apps/index.ts'), + }, // Covers `data` / `system` / `kernel` / `api` / `contracts` / `ui` / // `shared` and, [ADR-0105 D1], `security` reached transitively via // `@objectstack/types` (tenancy posture). From bde425d09390866e9acef8ffb36e373b8dbf1586 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:13:26 +0000 Subject: [PATCH 6/7] fix(i18n): wire the ninth bundle set and empty the unserved-provenance ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declares @objectstack/platform-objects in plugin-webhooks — already in its install closure via service-messaging, so this declares a resolution that already resolved rather than adding a package to the graph. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- packages/plugins/plugin-webhooks/package.json | 1 + .../plugin-webhooks/src/translations/index.ts | 50 +++++++++++-- pnpm-lock.yaml | 3 + scripts/check-i18n-stale-fill.mjs | 70 ++++++++++++++----- 4 files changed, 102 insertions(+), 22 deletions(-) diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index ba903777d4..af4f1ba1a1 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/platform-objects": "workspace:*", "@objectstack/service-messaging": "workspace:*", "@objectstack/spec": "workspace:*" }, diff --git a/packages/plugins/plugin-webhooks/src/translations/index.ts b/packages/plugins/plugin-webhooks/src/translations/index.ts index fb1115bd09..686646fac5 100644 --- a/packages/plugins/plugin-webhooks/src/translations/index.ts +++ b/packages/plugins/plugin-webhooks/src/translations/index.ts @@ -9,15 +9,55 @@ * `scripts/i18n-extract.config.ts`. */ -import type { TranslationBundle } from '@objectstack/spec/system'; +import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { withSourceFallback } from '@objectstack/platform-objects/apps'; import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; +import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; +import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; + +/** + * ## The provenance companions are READ here, not merely recorded + * + * `os i18n extract --source-hashes` writes `.source-hashes.generated.ts` + * beside these bundles (maintainer ruling #12069 Option A, #11671). A record + * says: "this locale's leaf at that path is still a byte copy of THAT source + * revision". Recording alone changes nothing a user sees — the substitution is + * what {@link withSourceFallback} does, and until it was wired here this set + * recorded the drift and went on serving the superseded draft. + * + * That gap was invisible by construction: a leaf revised in ONE locale is + * reported by `findStaleFills`, and every gate stays green — `check:i18n` + * compares key sets, `check:i18n-coverage` counts a stale leaf as translated, + * and `check:i18n-stale-fill` needs two locales holding the same stale bytes + * before it can testify. So the only reader-visible consequence was the wrong + * string on the page. + * + * This set was the LAST of the nine to be wired, and the reason is worth + * keeping: `withSourceFallback` lives in `@objectstack/platform-objects`, which + * this package did not declare. It was already in this package's install + * closure through `@objectstack/service-messaging`, so relying on it without + * declaring it would have been a phantom dependency; the manifest now declares + * the resolution that already resolved, and no package joined the graph. + * + * `recorded` (3rd argument) stays `undefined` on purpose: it judges the + * HAND-AUTHORED sections (`apps` / `dashboards` / `pages`), which this set does + * not have — its bundles are entirely generated. The companion goes in the 4th + * slot, which judges the generated ones. This is the shape + * `@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses. + * + * ⛔ Do not drop the 4th argument to quiet a staleness report. Serving the + * superseded draft is the bug; `check:i18n-stale-fill`'s UNSERVED PROVENANCE + * verdict fails the build if a committed companion stops being consulted here. + */ +const enSource: TranslationData = { objects: enObjects }; export const WebhooksTranslations: TranslationBundle = { - en: { objects: enObjects }, - 'zh-CN': { objects: zhCNObjects }, - 'ja-JP': { objects: jaJPObjects }, - 'es-ES': { objects: esESObjects }, + en: enSource, + 'zh-CN': withSourceFallback({ objects: zhCNObjects }, enSource, undefined, zhCNGeneratedSourceHashes), + 'ja-JP': withSourceFallback({ objects: jaJPObjects }, enSource, undefined, jaJPGeneratedSourceHashes), + 'es-ES': withSourceFallback({ objects: esESObjects }, enSource, undefined, esESGeneratedSourceHashes), }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 793bc67e3a..251ad5b614 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1831,6 +1831,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/platform-objects': + specifier: workspace:* + version: link:../../platform-objects '@objectstack/service-messaging': specifier: workspace:* version: link:../../services/service-messaging diff --git a/scripts/check-i18n-stale-fill.mjs b/scripts/check-i18n-stale-fill.mjs index 4c8f4f1f6a..0946efd969 100644 --- a/scripts/check-i18n-stale-fill.mjs +++ b/scripts/check-i18n-stale-fill.mjs @@ -345,24 +345,28 @@ const PROVENANCE_SEAM = 'withSourceFallback'; * Bundle sets that commit a provenance companion and deliberately do NOT * consult it, keyed by the extract config's own `--out=` directory. * + * ⭐ **Currently EMPTY, and empty is the load-bearing state**: all nine bundle + * sets read their companions at serving time. An empty registry is where a + * guard most often degenerates into a no-op, so the discrimination is proven + * mechanically rather than assumed — `--self-test` drives the empty-ledger + * cases directly (an empty ledger with everything served PASSES; an empty + * ledger with one set recording-but-not-serving FAILS), and the two-sided + * comparison below is the SAME {@link ratchet} the stale-fill verdict uses, + * not a second copy of that logic. + * * Hand-maintained and shrink-only, with no `--update` that can grow it: an * entry here is a decision someone made and wrote down, not a measurement to * be re-taken. The gate fails BOTH ways — a set that starts serving its * companion must delete its entry in the same PR, so the ledger cannot outlive - * the hole it documents. + * the hole it documents. That is why this object is empty rather than deleted: + * `@objectstack/plugin-webhooks` sat here while its dependency question was + * open, and its entry was removed by the change that wired it. + * + * ⛔ Adding an entry is not the remedy for a red verdict. The remedy is to pass + * the companion to `withSourceFallback` in the set's serving barrel. An entry + * is for a set that genuinely cannot reach the seam, and it must say why. */ -const UNSERVED_PROVENANCE = { - 'packages/plugins/plugin-webhooks/src/translations': - '@objectstack/plugin-webhooks does not depend on @objectstack/platform-objects, where ' + - '`withSourceFallback` lives — its dependencies are @objectstack/core, ' + - '@objectstack/service-messaging and @objectstack/spec. Wiring it needs either a new ' + - 'package edge or the mechanism relocated to a package all nine sets reach, and the ' + - 'nine share no runtime dependency but @objectstack/spec. That is an architecture ' + - 'call, not a mechanical follow-up, so it is open rather than forced. Recorded ' + - '2026-08-27, when the other eight sets were wired: this set records provenance for 20 ' + - 'leaves across three locales and serves the superseded draft when a source moves under ' + - 'one of them. Delete this entry in the PR that wires it.', -}; +const UNSERVED_PROVENANCE = {}; /** The identifier a provenance companion exports, read from the file itself. */ export function provenanceExportName(source) { @@ -688,6 +692,33 @@ function selfTest() { provenanceExportName('// just a comment') === undefined, ); + // ---- Verdict 2, the EMPTY-LEDGER cases --------------------------------- + // + // `UNSERVED_PROVENANCE` is empty on this tree: all nine bundle sets read + // their companions. An empty registry is the state where a guard most often + // degenerates into a no-op — it can no longer be observed doing anything, and + // "nothing to declare" reads exactly like "nothing is checked". So the + // discrimination is driven here rather than inferred from a green run. The + // verdict compares through this same `ratchet`, so these three cases ARE the + // verdict's decision procedure, not a model of it. + const emptyClean = ratchet([], []); + expect( + 'EMPTY ledger + every set serving its companion ⇒ no finding (the gate passes, correctly)', + emptyClean.added.length === 0 && emptyClean.removed.length === 0, + ); + const emptyBreached = ratchet(['packages/plugins/plugin-x/src/translations'], []); + expect( + '⭐ EMPTY ledger + a set that RECORDS-BUT-DOES-NOT-SERVE ⇒ still reported (the emptied ledger did NOT disarm the gate)', + emptyBreached.added.length === 1 && + emptyBreached.added[0] === 'packages/plugins/plugin-x/src/translations' && + emptyBreached.removed.length === 0, + ); + const stale = ratchet([], ['packages/plugins/plugin-x/src/translations']); + expect( + 'a ledger entry whose set now SERVES its companion is reported for deletion (shrink-only, both directions)', + stale.removed.length === 1 && stale.added.length === 0, + ); + console.log(failures === 0 ? '\ncheck-i18n-stale-fill: self-test OK\n' : `\ncheck-i18n-stale-fill: self-test FAILED (${failures})\n`); process.exit(failures === 0 ? 0 : 1); } @@ -728,14 +759,19 @@ function judgeProvenanceServing() { unservedByOut.get(row.out).push(row); } - const declared = new Set(Object.keys(UNSERVED_PROVENANCE)); - const undeclared = [...unservedByOut.keys()].filter((out) => !declared.has(out)).sort(); - const repaired = [...declared].filter((out) => !unservedByOut.has(out)).sort(); + // The SAME two-sided ratchet the stale-fill verdict uses, over out-dirs + // instead of leaf ids — one pure function, driven by `--self-test` for both + // verdicts, including the empty-ledger cases this registry now lives in. + const declared = Object.keys(UNSERVED_PROVENANCE).sort(); + const { added: undeclared, removed: repaired } = ratchet( + [...unservedByOut.keys()].sort(), + declared, + ); console.log( `check-i18n-stale-fill: ${rows.length} provenance companion(s), ` + `${rows.filter((r) => r.served).length} served at serving time, ` + - `${unservedByOut.size} bundle set(s) unserved (${declared.size} declared).`, + `${unservedByOut.size} bundle set(s) unserved (${declared.length} declared).`, ); if (undeclared.length) { From ae7a467d1497d193df69619457f9fc688c38260d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:19:13 +0000 Subject: [PATCH 7/7] chore(i18n): alias the provenance seam to source in plugin-webhooks tests; changeset covers nine sets Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- .../i18n-provenance-served-in-eight-sets.md | 31 ++++++++++++------- .../plugins/plugin-webhooks/vitest.config.ts | 8 +++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.changeset/i18n-provenance-served-in-eight-sets.md b/.changeset/i18n-provenance-served-in-eight-sets.md index 5bcdfe7555..d13a708c80 100644 --- a/.changeset/i18n-provenance-served-in-eight-sets.md +++ b/.changeset/i18n-provenance-served-in-eight-sets.md @@ -3,6 +3,7 @@ "@objectstack/plugin-audit": patch "@objectstack/plugin-security": patch "@objectstack/plugin-sharing": patch +"@objectstack/plugin-webhooks": patch "@objectstack/service-messaging": patch "@objectstack/service-realtime": patch "@objectstack/service-storage": patch @@ -26,18 +27,24 @@ counts a present leaf as translated, and `check:i18n-stale-fill`'s cross-locale rule needs a SECOND locale holding the same stale bytes before it can testify. The measured case had one locale and no second witness. -Seven of the eight are wired here, in the shape -`@objectstack/platform-objects`'s own `metadata-translations/index.ts` uses — -the committed `.source-hashes.generated.ts` passed as the fourth -argument, the third left `undefined` because these sets have no hand-authored -sections. `@objectstack/plugin-webhooks` is the eighth and is NOT wired: it does -not depend on `@objectstack/platform-objects`, where the seam lives, and the -nine sets share no runtime dependency but `@objectstack/spec`. Wiring it needs -either a new package edge or the mechanism relocated, which is an architecture -call rather than a mechanical follow-up. It is recorded in -`check:i18n-stale-fill`'s `UNSERVED_PROVENANCE` ledger with the reason, and that -gate's new **UNSERVED PROVENANCE** verdict now fails the build for any other set -that commits a companion and does not read it. +All eight are wired here, in the shape `@objectstack/platform-objects`'s own +`metadata-translations/index.ts` uses — the committed +`.source-hashes.generated.ts` passed as the fourth argument, the third +left `undefined` because these sets have no hand-authored sections. Provenance +is now recorded in 9 of 9 sets and served in 9 of 9. + +`@objectstack/plugin-webhooks` was the last of them and is the only one whose +manifest changed: `withSourceFallback` lives in `@objectstack/platform-objects`, +which that package did not declare. It was **already in that package's install +closure** through `@objectstack/service-messaging`, so the edge declares a +resolution that already resolved rather than adding a package to the graph — +and relying on it undeclared would have been a phantom dependency under this +repo's strict package manager. + +`check:i18n-stale-fill` gains a second verdict, **UNSERVED PROVENANCE**, so this +cannot silently come apart again: a bundle set that commits a companion and +does not consult it at serving time now fails the build, including a tenth set +that lands tomorrow. **Graded `patch`, and the grade is the interesting part.** No API changes, no new exported surface, and no key set moves — substitution was chosen over diff --git a/packages/plugins/plugin-webhooks/vitest.config.ts b/packages/plugins/plugin-webhooks/vitest.config.ts index 1851931a9f..33317de9c0 100644 --- a/packages/plugins/plugin-webhooks/vitest.config.ts +++ b/packages/plugins/plugin-webhooks/vitest.config.ts @@ -32,6 +32,14 @@ export default defineConfig({ resolve: { alias: [ { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + // [#12642] The i18n provenance seam. This package's translation + // barrel passes its committed `.source-hashes.generated.ts` + // companions through `withSourceFallback`, whose home is this + // subpath — so without the alias the suite's verdict would be about + // `platform-objects/dist` build state rather than the checkout. + // Anchored on the SUBPATH for the same reason the `core` entry + // above is anchored on the root. + { find: /^@objectstack\/platform-objects\/apps$/, replacement: path.resolve(__dirname, '../../platform-objects/src/apps/index.ts') }, ], }, });