Skip to content

Commit 431c757

Browse files
claude[bot]claude
andauthored
test(cli): pin the generated apps leaf that no provenance predicate judges (#17726)
`os i18n extract --no-objects-only --fill=default --source-hashes` emits `apps.*` leaves filled from the source locale — leaves with exactly the property the generated predicate exists to judge — and neither mechanism reaches them: `findStaleFills` walks `GENERATED_SECTIONS` (`['objects','metadataForms']`), and `findStaleLeaves` judges `apps` against the hand-maintained `<locale>.source-hashes.ts`, which carries no entry for a generated leaf. Characterization pin only. The population lives in `@objectstack/platform-objects` and the fix cannot land in this package, so this file is what reddens the moment the population moves. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6fa2a8a commit 431c757

1 file changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `os i18n extract --no-objects-only --fill=default --source-hashes` emits
4+
// `apps.*` leaves that NO predicate judges (#16872).
5+
//
6+
// ## What this file pins, and what it deliberately does NOT
7+
//
8+
// ⚠️ These assertions record the GAP as it stands. They are a characterization
9+
// pin, not a statement that the current split is right: the card's whole point
10+
// is that a generated `apps` leaf should be judged by SOME predicate and today
11+
// is judged by none. Closing it moves the POPULATION the generated predicate
12+
// walks, which lives in `@objectstack/platform-objects`
13+
// (`GENERATED_SECTIONS` / `collectGeneratedLeaves`) and therefore NOT in this
14+
// package — so the fix cannot land here and this file is what makes the gap
15+
// fail loudly the moment it does. ⛔ Whoever closes #16872: these expectations
16+
// are meant to flip, and flipping them is the deliberate act this pin exists to
17+
// force. ⛔ Do not delete the file to make a red go away.
18+
//
19+
// ## Why a leaf in the difference is unreachable by BOTH mechanisms
20+
//
21+
// - `findStaleFills` (the generated predicate) walks `GENERATED_SECTIONS`
22+
// = ['objects', 'metadataForms']. `apps` is not in it, so no `apps.*`
23+
// record is ever WRITTEN by `collectFilledFromHashes` and none is ever
24+
// READ back.
25+
// - `findStaleLeaves` (the hand-authored predicate) walks
26+
// `HAND_AUTHORED_SECTIONS` = ['apps', 'dashboards', 'pages'] — it does
27+
// reach the path, but it judges against the hand-maintained
28+
// `<locale>.source-hashes.ts`, which by construction carries no entry for
29+
// a leaf a generator produced.
30+
//
31+
// So the leaf is legacy-trusted forever, and a `--fill=default` copy left
32+
// behind by a revised source is served as a superseded draft under a green
33+
// `check:i18n` — the harm #16242 asserted and attributed to the wrong step.
34+
35+
import { describe, it, expect } from 'vitest';
36+
import {
37+
extractTranslations,
38+
translationModuleSections,
39+
} from '../src/utils/i18n-extract.js';
40+
import {
41+
GENERATED_SECTIONS,
42+
HAND_AUTHORED_SECTIONS,
43+
collectFilledFromHashes,
44+
findStaleFills,
45+
findStaleLeaves,
46+
withSourceFallback,
47+
} from '@objectstack/platform-objects/apps';
48+
49+
const APP_V1 = 'Key Performance Indicators';
50+
const APP_V2 = 'KPI Cockpit'; // the source label, revised
51+
const OBJ_HELP = 'The metric this KPI tracks.';
52+
53+
const APP_LEAF = 'apps.kpi.label';
54+
const OBJ_LEAF = 'objects.kpi_metric.fields.name.help';
55+
56+
const stack = (appLabel: string) => ({
57+
objects: [
58+
{
59+
name: 'kpi_metric',
60+
label: 'KPI Metric',
61+
fields: { name: { type: 'text', label: 'Name', help: OBJ_HELP } },
62+
},
63+
],
64+
apps: [{ name: 'kpi', label: appLabel }],
65+
});
66+
67+
/** Run 1: every leaf arrives as a `--fill=default` byte copy of the source. */
68+
const filled = () =>
69+
extractTranslations(stack(APP_V1) as never, { locales: ['zh-CN'], fill: 'default' });
70+
71+
describe('the two section sets do not partition what a `--no-objects-only` run emits', () => {
72+
it('leaves `apps` outside the generated population while the run commits it', () => {
73+
const r = filled();
74+
// The section list the COMMAND commits under `kind: 'stack'` is derived
75+
// from the payload (#16242), so it already names every group the module
76+
// holds — `apps` included.
77+
const committed = translationModuleSections(r.bundles['zh-CN'], 'stack');
78+
expect(committed).toContain('apps');
79+
80+
// The population the provenance RULE walks does not.
81+
expect([...GENERATED_SECTIONS]).toEqual(['objects', 'metadataForms']);
82+
expect([...GENERATED_SECTIONS]).not.toContain('apps');
83+
84+
// ⇒ the difference is non-empty, and every leaf in it is un-judged. This
85+
// inequality IS the card.
86+
const uncovered = committed.filter((s) => !GENERATED_SECTIONS.includes(s as never));
87+
expect(uncovered).toContain('apps');
88+
});
89+
90+
it('puts `apps` in the hand-authored set, whose table a generator never writes', () => {
91+
expect([...HAND_AUTHORED_SECTIONS]).toContain('apps');
92+
});
93+
});
94+
95+
describe('an armed run records the object leaf and not the app leaf', () => {
96+
it('writes no provenance record for a filled `apps` leaf', () => {
97+
const r = filled();
98+
const bundle = r.bundles['zh-CN'] as Record<string, never>;
99+
const table = r.sourceHashes['zh-CN'];
100+
101+
// Both leaves are byte copies of the source — the exact property the
102+
// generated predicate exists to judge.
103+
expect((bundle as never as { apps: { kpi: { label: string } } }).apps.kpi.label).toBe(APP_V1);
104+
105+
expect(table[OBJ_LEAF]).toMatch(/^[0-9a-f]{16}$/); // judged
106+
expect(table[APP_LEAF]).toBeUndefined(); // NOT judged — the gap
107+
108+
// Stated as a set so a third generated section cannot slip in unnoticed.
109+
expect([...new Set(Object.keys(table).map((k) => k.split('.')[0]))].sort()).toEqual([
110+
'metadataForms',
111+
'objects',
112+
]);
113+
});
114+
115+
it('serves the superseded draft after the source moves, with nothing on disk recording it', () => {
116+
const first = filled();
117+
const b1 = first.bundles['zh-CN'];
118+
119+
// The source label is revised; merge keeps the existing (now stale) fill.
120+
const second = extractTranslations(
121+
{ ...stack(APP_V2), translations: [{ 'zh-CN': b1 }] } as never,
122+
{
123+
locales: ['zh-CN'],
124+
fill: 'default',
125+
previousSourceHashes: { 'zh-CN': first.sourceHashes['zh-CN'] },
126+
},
127+
);
128+
const b2 = second.bundles['zh-CN'];
129+
const en2 = second.bundles.en;
130+
const t2 = second.sourceHashes['zh-CN'];
131+
132+
const served = b2 as never as { apps: { kpi: { label: string } } };
133+
expect(served.apps.kpi.label).toBe(APP_V1); // drifted from the source
134+
expect((en2 as never as { apps: { kpi: { label: string } } }).apps.kpi.label).toBe(APP_V2);
135+
136+
// Neither predicate can see it, so the serving path substitutes nothing.
137+
expect(findStaleFills(b2, en2, t2).map((f) => f.path)).not.toContain(APP_LEAF);
138+
expect(findStaleLeaves(b2, en2, t2).map((f) => f.path)).not.toContain(APP_LEAF);
139+
const out = withSourceFallback(b2, en2, t2, t2) as never as {
140+
apps: { kpi: { label: string } };
141+
};
142+
expect(out.apps.kpi.label).toBe(APP_V1); // the superseded draft is served
143+
});
144+
});
145+
146+
describe('the defect is the population, not the rule', () => {
147+
it('records the very same leaf once it is walked under a generated section name', () => {
148+
const r = filled();
149+
const b = r.bundles['zh-CN'] as never as { apps: unknown };
150+
const en = r.bundles.en as never as { apps: unknown };
151+
152+
// Re-rooted under a name `collectGeneratedLeaves` walks — the ONLY thing
153+
// that changes is which section the leaf sits under. ⛔ Not a proposed
154+
// fix: re-rooting corrupts the dotted path a record is keyed by, which is
155+
// why the population, not the caller's tree, is the thing to move.
156+
const asIfGenerated = collectFilledFromHashes(
157+
{ objects: b.apps } as never,
158+
{ objects: en.apps } as never,
159+
undefined,
160+
);
161+
162+
expect(asIfGenerated['objects.kpi.label']).toMatch(/^[0-9a-f]{16}$/);
163+
});
164+
});
165+
166+
describe('the default `--objects-only` path is untouched by any of this', () => {
167+
it('reaches the same provenance table whether or not the run would emit apps', () => {
168+
// `extractTranslations` builds the whole skeleton either way — the flag
169+
// picks the RENDERED sub-tree, not the walk — so the provenance table for
170+
// the default path is byte-identical to the armed one. That is why closing
171+
// #16872 in the population can move the armed path without moving the nine
172+
// live `--source-hashes` configs, all of which run `--objects-only`.
173+
const armed = filled().sourceHashes['zh-CN'];
174+
const objectsOnlySections = translationModuleSections(
175+
filled().bundles['zh-CN'],
176+
'objects',
177+
);
178+
expect(objectsOnlySections).toEqual(['objects']);
179+
// Every section the default path commits is already inside the population.
180+
for (const s of objectsOnlySections) {
181+
expect(GENERATED_SECTIONS).toContain(s as never);
182+
}
183+
expect(Object.keys(armed).some((k) => k.startsWith('objects.'))).toBe(true);
184+
});
185+
});

0 commit comments

Comments
 (0)