@@ -79,6 +79,7 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
7979import { deriveFieldGroupLayout } from '@objectstack/spec/data' ;
8080import { expandViewContainer } from '@objectstack/spec/ui' ;
8181import { walkPageComponents } from '@objectstack/lint' ;
82+ import { collectFilledFromHashes } from '@objectstack/platform-objects/apps' ;
8283
8384// ─── Public types ──────────────────────────────────────────────────────
8485
@@ -158,6 +159,18 @@ export interface ExtractOptions {
158159 * This makes extract idempotent — re-running only fills the gaps.
159160 */
160161 mergeExisting ?: boolean ;
162+ /**
163+ * The `<locale>.source-hashes.generated.ts` tables already committed beside
164+ * the bundles, keyed by locale.
165+ *
166+ * This is the mechanism's ONLY memory (#11671 / #12069 Option A): a leaf that
167+ * is a byte copy of a source revision keeps its record across runs, which is
168+ * what makes the drift detectable after the source moves. Passing nothing
169+ * makes the run behave like a first extract — every record is re-derived from
170+ * the tree, so leaves that already drifted stay legacy-trusted rather than
171+ * being reported.
172+ */
173+ previousSourceHashes ?: Record < string , Record < string , string > > ;
161174}
162175
163176export interface ExtractResult {
@@ -167,6 +180,18 @@ export interface ExtractResult {
167180 counts : Record < string , number > ;
168181 /** Total expected entries before per-locale merge filtering. */
169182 totalExpected : number ;
183+ /**
184+ * Per translated locale, the digest of the source revision each GENERATED
185+ * leaf is still a byte copy of — the content of
186+ * `<locale>.source-hashes.generated.ts`.
187+ *
188+ * Computed by `collectFilledFromHashes` in
189+ * `@objectstack/platform-objects/apps`, the module maintainer ruling #8765
190+ * Option B put the mechanism in; the extractor supplies the tree and the
191+ * previous records and owns none of the rule. The default locale gets no
192+ * entry: it is the source, not a copy of one.
193+ */
194+ sourceHashes : Record < string , Record < string , string > > ;
170195}
171196
172197// ─── Walk helpers ──────────────────────────────────────────────────────
@@ -1233,7 +1258,18 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext
12331258 counts [ locale ] = count ;
12341259 }
12351260
1236- return { bundles, counts, totalExpected : entries . length } ;
1261+ const sourceHashes : Record < string , Record < string , string > > = { } ;
1262+ const sourceBundle = bundles [ defaultLocale ] ;
1263+ for ( const locale of locales ) {
1264+ if ( locale === defaultLocale ) continue ;
1265+ sourceHashes [ locale ] = collectFilledFromHashes (
1266+ bundles [ locale ] ,
1267+ sourceBundle ,
1268+ opts . previousSourceHashes ?. [ locale ] ,
1269+ ) ;
1270+ }
1271+
1272+ return { bundles, counts, totalExpected : entries . length , sourceHashes } ;
12371273}
12381274
12391275// ─── Serialization ─────────────────────────────────────────────────────
@@ -1310,6 +1346,80 @@ export function renderTranslationModule(
13101346 return lines . join ( '\n' ) ;
13111347}
13121348
1349+ /**
1350+ * Render one locale's generated source-hash table as a TypeScript module body —
1351+ * the `<locale>.source-hashes.generated.ts` companion.
1352+ *
1353+ * Deliberately types the export STRUCTURALLY (`Readonly<Record<string,
1354+ * string>>`) instead of importing `SourceHashes`. The companion is written into
1355+ * whichever package owns the bundles, and only one of those packages can spell
1356+ * the type with a relative import; an import path guessed per package is a
1357+ * portability bug waiting for the second package to use this. The structural
1358+ * type is what `SourceHashes` is defined as, so nothing is lost.
1359+ *
1360+ * Keys are emitted sorted, and every key is quoted (they are dotted paths, so
1361+ * `formatKey` would quote them anyway). Both are load-bearing for `--check`:
1362+ * the comparison is byte-for-byte, so a table that reordered with the walk
1363+ * would fail on a tree that is in fact in sync.
1364+ */
1365+ export function renderSourceHashModule (
1366+ hashes : Record < string , string > ,
1367+ options : { locale : string ; exportName ?: string } ,
1368+ ) : string {
1369+ const exportName = options . exportName ?? `${ camelize ( options . locale ) } GeneratedSourceHashes` ;
1370+ const keys = Object . keys ( hashes ) . sort ( ) ;
1371+ const lines : string [ ] = [ ] ;
1372+ lines . push ( '// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.' ) ;
1373+ lines . push ( '' ) ;
1374+ lines . push ( '/**' ) ;
1375+ lines . push ( ` * Auto-generated by 'os i18n extract' for locale '${ options . locale } '. Do not hand-edit.` ) ;
1376+ lines . push ( ' *' ) ;
1377+ lines . push ( " * Each entry is the digest of the SOURCE REVISION that this locale's leaf at" ) ;
1378+ lines . push ( ' * that path is still a byte copy of — provenance for the generated half of the' ) ;
1379+ lines . push ( ' * bundles (#11671, maintainer ruling #12069 Option A, extending #8765 Option B).' ) ;
1380+ lines . push ( ' *' ) ;
1381+ lines . push ( ' * An entry exists only while the leaf IS such a copy. Re-translate the leaf in' ) ;
1382+ lines . push ( ' * `<locale>.objects.generated.ts` and the next extract drops its entry by' ) ;
1383+ lines . push ( ' * itself — the table makes no claim about text a translator wrote. A path with' ) ;
1384+ lines . push ( ' * no entry is LEGACY-TRUSTED and never reported stale.' ) ;
1385+ lines . push ( ' *' ) ;
1386+ lines . push ( ' * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest' ) ;
1387+ lines . push ( ' * records that the current text was copied from the current source, which is' ) ;
1388+ lines . push ( ' * the false claim the mechanism exists to detect. Fix the TRANSLATION.' ) ;
1389+ lines . push ( ' */' ) ;
1390+ lines . push ( '' ) ;
1391+ lines . push ( `export const ${ exportName } : Readonly<Record<string, string>> = {` ) ;
1392+ for ( const key of keys ) lines . push ( ` ${ JSON . stringify ( key ) } : ${ JSON . stringify ( hashes [ key ] ) } ,` ) ;
1393+ lines . push ( '};' ) ;
1394+ lines . push ( '' ) ;
1395+ return lines . join ( '\n' ) ;
1396+ }
1397+
1398+ /**
1399+ * Read a committed `<locale>.source-hashes.generated.ts` back into a table.
1400+ *
1401+ * The module body is written by {@link renderSourceHashModule}, which quotes
1402+ * every key and every value, so the object literal is already valid JSON — the
1403+ * parse needs no TypeScript and no evaluation. A file that does not parse is a
1404+ * hard `undefined` (treated as "no previous records", i.e. everything
1405+ * legacy-trusted) rather than a guess: inventing records from a file we cannot
1406+ * read is how a mechanism starts asserting provenance it does not have.
1407+ */
1408+ export function parseSourceHashModule ( source : string ) : Record < string , string > | undefined {
1409+ const marker = source . indexOf ( 'export const' ) ;
1410+ const open = marker < 0 ? - 1 : source . indexOf ( '= {' , marker ) ;
1411+ if ( open < 0 ) return undefined ;
1412+ const literal = source . slice ( open + 2 ) . replace ( / ; \s * $ / , '' ) ;
1413+ try {
1414+ const parsed = JSON . parse ( literal . replace ( / , ( \s * } ) / g, '$1' ) ) ;
1415+ if ( ! parsed || typeof parsed !== 'object' || Array . isArray ( parsed ) ) return undefined ;
1416+ for ( const value of Object . values ( parsed ) ) if ( typeof value !== 'string' ) return undefined ;
1417+ return parsed as Record < string , string > ;
1418+ } catch {
1419+ return undefined ;
1420+ }
1421+ }
1422+
13131423function camelize ( locale : string ) : string {
13141424 // 'zh-CN' → 'zhCN', 'ja-JP' → 'jaJP', 'es-ES' → 'esES'
13151425 return locale . replace ( / - ( .) / g, ( _m , c ) => c . toUpperCase ( ) ) ;
0 commit comments