Skip to content

Commit f570c28

Browse files
claude[bot]claude
andauthored
fix(cli): the i18n provenance companion accompanies a module, and names its sections from their payloads (#16871)
`os i18n extract --source-hashes` narrowed the provenance table to "the sections this run commits" and built that list from two literals. The half deciding WHICH modules were emitted already read the emitted set; the half naming them pushed 'objects' / 'metadataForms'. Two consequences, one cause: - With no module emitted the list is empty, narrowToCommittedSections returns {}, and {} is truthy at the emit gate — so the run wrote a zero-record companion with no bundle module beside it. Because --check compares the companion by bytes like any other emitted file, that orphan once committed is a file the gate demands forever. - Under kind: 'stack' the module holds every group the stack authors while the caller named one of them — correct only for as long as no other section can reach the table. translationModuleSections() now sits beside translationModulePayload and is switched on the same kind, so what a module holds and which sections it commits are one decision. The emit gate returns undefined for an empty set. No provenance record moves in this repository: the tables only ever carry the two sections collectFilledFromHashes walks, so 'objects' was the right name for both stack sub-tree modes — correct by coincidence, now by construction. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4771bd9 commit f570c28

5 files changed

Lines changed: 406 additions & 5 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os i18n extract --source-hashes` no longer writes a provenance companion with no bundle module beside it, and names the sections it commits from the payloads those modules hold instead of from two literals.
6+
7+
The command narrows the provenance table to "the sections this run commits" before writing `<locale>.source-hashes.generated.ts`. The half that decided WHICH modules were emitted already read the emitted set; the half that named them pushed the string `'objects'` or `'metadataForms'`.
8+
9+
- **A zero-record orphan is no longer written.** With no module emitted for a locale — a stack whose only surface is apps, under the default `--objects-only` with `--no-metadata-forms` — the committed-section list is empty, `narrowToCommittedSections` returns `{}`, and `{}` is truthy at the emit gate. The run therefore wrote one file holding an empty table, describing nothing, with no bundle module beside it for it to be about. Because `--check` compares the companion by bytes like any other emitted file, that orphan once committed is a file the gate demands forever: deleting it made `--check` report `missing` and exit 1. Such a run now writes nothing, and reports `Generated 0 file(s)`.
10+
- **The section list is derived.** `translationModuleSections(bundle, kind)` sits beside `translationModulePayload` and is switched on the same `kind`, so what a module holds and which sections it commits are one decision rather than two. Under `kind: 'stack'` the module holds every group the stack authors and the caller now names all of them; a group added later needs no edit, and a further aggregate kind fails to compile at that one site rather than silently committing its own name as a section.
11+
12+
**No provenance record changes in this repository, and none is restored.** The generated tables only ever carry the two sections `collectFilledFromHashes` walks (`objects` and `metadataForms`), so `'objects'` was the right name for both stack sub-tree modes — the old list was correct by coincidence, not by construction. In particular an `apps.*` record is not restored by this change: no such record is built, so none was being filtered out.
13+
14+
**Already committed an empty companion?** Nothing needs doing and nothing is deleted. `--check` compares only the files a run writes and reports `missing` / `stale` over that set, so a leftover empty companion is in neither category — it is tolerated where it sits, and is inert to the next extract, which reads it back as an empty record set exactly as it would read its absence. Delete it at your convenience.

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseSourceHashModule,
2525
narrowToCommittedSections,
2626
translationModulePayload,
27+
translationModuleSections,
2728
countTranslationLeaves,
2829
type FillStrategy,
2930
type TranslationModuleKind,
@@ -448,15 +449,44 @@ export default class I18nExtract extends Command {
448449
* bundle files — {@link emittedModules} — never by a second rule. A set that commits both —
449450
* `platform-objects` is the one today — keeps every record it had. The
450451
* narrowing itself is `narrowToCommittedSections`, a pure function in the
451-
* extractor's utils so it can be pinned without driving oclif; this layer
452-
* contributes only the two booleans it alone knows.
452+
* extractor's utils so it can be pinned without driving oclif.
453+
*
454+
* ⭐ And the sections are read off the PAYLOADS those modules hold
455+
* (`translationModuleSections`), not written here as literals. This layer
456+
* used to push `'objects'` and `'metadataForms'` — the emitted-module half
457+
* already read `emittedModules`, but what it pushed was a hand-copied
458+
* name, so under `kind: 'stack'` it named one of the several groups the
459+
* module actually commits. Nothing in this repository's provenance tables
460+
* is filtered by that mismatch today, because the tables only ever carry
461+
* the two GENERATED sections (`GENERATED_SECTIONS` in
462+
* `@objectstack/platform-objects`), and `'objects'` is the right name for
463+
* both stack kinds — the list was correct by COINCIDENCE, not by
464+
* construction, and a third generated section would have broken it
465+
* silently. It is now derived.
466+
*
467+
* ⭐ Returning `undefined` when nothing is committed is the second half,
468+
* and it is a file-set decision rather than a narrowing one:
469+
* `narrowToCommittedSections` returns `{}` for an empty section set, `{}`
470+
* is truthy at the emit site, and the run therefore wrote a zero-record
471+
* companion with NO bundle module beside it for it to be about. `--check`
472+
* compares the companion by bytes like any other emitted file, so that
473+
* orphan, once committed, is a file the gate demands forever.
453474
*/
454475
const committedSourceHashes = (locale: string): Record<string, string> | undefined => {
455476
const table = result.sourceHashes[locale];
456477
if (!table) return undefined;
457-
const committed: string[] = [];
458-
if (emittedModules(locale).some((m) => m.kind !== 'metadataForms')) committed.push('objects');
459-
if (emittedModules(locale).some((m) => m.kind === 'metadataForms')) committed.push('metadataForms');
478+
const committed = new Set<string>();
479+
for (const mod of emittedModules(locale)) {
480+
for (const section of translationModuleSections(result.bundles[locale], mod.kind)) {
481+
committed.add(section);
482+
}
483+
}
484+
// No module is committed for this locale, so there is nothing beside a
485+
// companion for it to be ABOUT — and an orphan is worse than nothing:
486+
// `--check` compares by bytes against the emitted list, so a zero-record
487+
// companion written once is a file the gate demands forever. `{}` is
488+
// truthy, so returning the narrowed table here wrote exactly that.
489+
if (committed.size === 0) return undefined;
460490
return narrowToCommittedSections(table, committed);
461491
};
462492

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1904,6 +1904,52 @@ export function translationModulePayload(
19041904
: stackAuthoredSubtree(data);
19051905
}
19061906

1907+
/**
1908+
* The `TranslationData` SECTIONS a module of the given kind COVERS — the first
1909+
* dotted segment its leaves carry, which is the identity
1910+
* {@link narrowToCommittedSections} narrows a provenance table by.
1911+
*
1912+
* Beside {@link translationModulePayload} and switched on the same `kind`, so
1913+
* "what a module holds" and "which sections it commits" are ONE decision. The
1914+
* caller was two literals — `committed.push('objects')` and
1915+
* `committed.push('metadataForms')`, chosen by which modules were emitted — and
1916+
* a literal cannot follow the payload: under `kind: 'stack'` the module holds
1917+
* every group the stack authors (`objects`, `apps`, `dashboards`, ...) while the
1918+
* caller named exactly one of them. Replacing that list with a longer list of
1919+
* literals only moves the day it goes wrong to the next group added.
1920+
*
1921+
* ⚠️ The sections are NOT simply the payload's own top-level keys, and reading
1922+
* them off it would be WRONG for two of the three kinds. `'objects'` and
1923+
* `'metadataForms'` select the sub-tree ROOTED AT one section, so those keys are
1924+
* object and form names (`kpi_metric`) — deriving the section list from them
1925+
* would commit `['kpi_metric']` and narrow away every `objects.*` record, which
1926+
* is the one path this repository's single `--source-hashes` config is on.
1927+
* `'stack'` selects a `TranslationData`-shaped subtree, so THERE the top-level
1928+
* keys are sections — every group the stack authors, today's and any added
1929+
* later, with nothing here to update.
1930+
*
1931+
* The switch is exhaustive on purpose: a fourth kind that selects one section
1932+
* needs no edit (`[kind]` already names it), and a fourth AGGREGATE kind fails
1933+
* to compile here rather than silently committing its own name as a section.
1934+
*/
1935+
export function translationModuleSections(
1936+
data: TranslationData,
1937+
kind: TranslationModuleKind,
1938+
): string[] {
1939+
switch (kind) {
1940+
case 'stack':
1941+
return Object.keys(stackAuthoredSubtree(data));
1942+
case 'objects':
1943+
case 'metadataForms':
1944+
// The selector's own name IS the section it roots at.
1945+
return [kind];
1946+
default: {
1947+
const exhaustive: never = kind;
1948+
return exhaustive;
1949+
}
1950+
}
1951+
}
1952+
19071953
/**
19081954
* String leaves under a nested translation tree.
19091955
*

0 commit comments

Comments
 (0)