Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/i18n-extract-companion-accompanies-a-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/cli": patch
---

`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.

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'`.

- **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)`.
- **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.

**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.

**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.
40 changes: 35 additions & 5 deletions packages/cli/src/commands/i18n/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
parseSourceHashModule,
narrowToCommittedSections,
translationModulePayload,
translationModuleSections,
countTranslationLeaves,
type FillStrategy,
type TranslationModuleKind,
Expand Down Expand Up @@ -448,15 +449,44 @@ export default class I18nExtract extends Command {
* bundle files — {@link emittedModules} — never by a second rule. A set that commits both —
* `platform-objects` is the one today — keeps every record it had. The
* narrowing itself is `narrowToCommittedSections`, a pure function in the
* extractor's utils so it can be pinned without driving oclif; this layer
* contributes only the two booleans it alone knows.
* extractor's utils so it can be pinned without driving oclif.
*
* ⭐ And the sections are read off the PAYLOADS those modules hold
* (`translationModuleSections`), not written here as literals. This layer
* used to push `'objects'` and `'metadataForms'` — the emitted-module half
* already read `emittedModules`, but what it pushed was a hand-copied
* name, so under `kind: 'stack'` it named one of the several groups the
* module actually commits. Nothing in this repository's provenance tables
* is filtered by that mismatch today, because the tables only ever carry
* the two GENERATED sections (`GENERATED_SECTIONS` in
* `@objectstack/platform-objects`), and `'objects'` is the right name for
* both stack kinds — the list was correct by COINCIDENCE, not by
* construction, and a third generated section would have broken it
* silently. It is now derived.
*
* ⭐ Returning `undefined` when nothing is committed is the second half,
* and it is a file-set decision rather than a narrowing one:
* `narrowToCommittedSections` returns `{}` for an empty section set, `{}`
* is truthy at the emit site, and the run therefore wrote a zero-record
* companion with NO bundle module beside it for it to be about. `--check`
* compares the companion by bytes like any other emitted file, so that
* orphan, once committed, is a file the gate demands forever.
*/
const committedSourceHashes = (locale: string): Record<string, string> | undefined => {
const table = result.sourceHashes[locale];
if (!table) return undefined;
const committed: string[] = [];
if (emittedModules(locale).some((m) => m.kind !== 'metadataForms')) committed.push('objects');
if (emittedModules(locale).some((m) => m.kind === 'metadataForms')) committed.push('metadataForms');
const committed = new Set<string>();
for (const mod of emittedModules(locale)) {
for (const section of translationModuleSections(result.bundles[locale], mod.kind)) {
committed.add(section);
}
}
// No module is committed for this locale, so there is nothing beside a
// companion for it to be ABOUT — and an orphan is worse than nothing:
// `--check` compares by bytes against the emitted list, so a zero-record
// companion written once is a file the gate demands forever. `{}` is
// truthy, so returning the narrowed table here wrote exactly that.
if (committed.size === 0) return undefined;
return narrowToCommittedSections(table, committed);
};

Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,52 @@ export function translationModulePayload(
: stackAuthoredSubtree(data);
}

/**
* The `TranslationData` SECTIONS a module of the given kind COVERS — the first
* dotted segment its leaves carry, which is the identity
* {@link narrowToCommittedSections} narrows a provenance table by.
*
* Beside {@link translationModulePayload} and switched on the same `kind`, so
* "what a module holds" and "which sections it commits" are ONE decision. The
* caller was two literals — `committed.push('objects')` and
* `committed.push('metadataForms')`, chosen by which modules were emitted — and
* a literal cannot follow the payload: under `kind: 'stack'` the module holds
* every group the stack authors (`objects`, `apps`, `dashboards`, ...) while the
* caller named exactly one of them. Replacing that list with a longer list of
* literals only moves the day it goes wrong to the next group added.
*
* ⚠️ The sections are NOT simply the payload's own top-level keys, and reading
* them off it would be WRONG for two of the three kinds. `'objects'` and
* `'metadataForms'` select the sub-tree ROOTED AT one section, so those keys are
* object and form names (`kpi_metric`) — deriving the section list from them
* would commit `['kpi_metric']` and narrow away every `objects.*` record, which
* is the one path this repository's single `--source-hashes` config is on.
* `'stack'` selects a `TranslationData`-shaped subtree, so THERE the top-level
* keys are sections — every group the stack authors, today's and any added
* later, with nothing here to update.
*
* The switch is exhaustive on purpose: a fourth kind that selects one section
* needs no edit (`[kind]` already names it), and a fourth AGGREGATE kind fails
* to compile here rather than silently committing its own name as a section.
*/
export function translationModuleSections(
data: TranslationData,
kind: TranslationModuleKind,
): string[] {
switch (kind) {
case 'stack':
return Object.keys(stackAuthoredSubtree(data));
case 'objects':
case 'metadataForms':
// The selector's own name IS the section it roots at.
return [kind];
default: {
const exhaustive: never = kind;
return exhaustive;
}
}
}

/**
* String leaves under a nested translation tree.
*
Expand Down
Loading
Loading