@babelize/engine 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,8 @@ export declare const lockfileEntrySchema: z.ZodObject<{
25
25
  * catalogue), not from Babelize: glossary edits don't overwrite it.
26
26
  */
27
27
  external: z.ZodOptional<z.ZodBoolean>;
28
+ /** Edited in the dashboard (a translation override), not translated by Babelize. */
29
+ manual: z.ZodOptional<z.ZodBoolean>;
28
30
  }, "strip", z.ZodTypeAny, {
29
31
  sourceHash?: string | undefined;
30
32
  fromMemory?: boolean | undefined;
@@ -32,6 +34,7 @@ export declare const lockfileEntrySchema: z.ZodObject<{
32
34
  pseudo?: boolean | undefined;
33
35
  glossaryHash?: string | undefined;
34
36
  external?: boolean | undefined;
37
+ manual?: boolean | undefined;
35
38
  }, {
36
39
  sourceHash?: string | undefined;
37
40
  fromMemory?: boolean | undefined;
@@ -39,6 +42,7 @@ export declare const lockfileEntrySchema: z.ZodObject<{
39
42
  pseudo?: boolean | undefined;
40
43
  glossaryHash?: string | undefined;
41
44
  external?: boolean | undefined;
45
+ manual?: boolean | undefined;
42
46
  }>>>;
43
47
  }, "strip", z.ZodTypeAny, {
44
48
  sourceHash: string;
@@ -51,6 +55,7 @@ export declare const lockfileEntrySchema: z.ZodObject<{
51
55
  pseudo?: boolean | undefined;
52
56
  glossaryHash?: string | undefined;
53
57
  external?: boolean | undefined;
58
+ manual?: boolean | undefined;
54
59
  }> | undefined;
55
60
  }, {
56
61
  sourceHash: string;
@@ -63,6 +68,7 @@ export declare const lockfileEntrySchema: z.ZodObject<{
63
68
  pseudo?: boolean | undefined;
64
69
  glossaryHash?: string | undefined;
65
70
  external?: boolean | undefined;
71
+ manual?: boolean | undefined;
66
72
  }> | undefined;
67
73
  }>;
68
74
  export type LockfileEntry = z.infer<typeof lockfileEntrySchema>;
@@ -96,6 +102,8 @@ export declare const lockfileDataSchema: z.ZodObject<{
96
102
  * catalogue), not from Babelize: glossary edits don't overwrite it.
97
103
  */
98
104
  external: z.ZodOptional<z.ZodBoolean>;
105
+ /** Edited in the dashboard (a translation override), not translated by Babelize. */
106
+ manual: z.ZodOptional<z.ZodBoolean>;
99
107
  }, "strip", z.ZodTypeAny, {
100
108
  sourceHash?: string | undefined;
101
109
  fromMemory?: boolean | undefined;
@@ -103,6 +111,7 @@ export declare const lockfileDataSchema: z.ZodObject<{
103
111
  pseudo?: boolean | undefined;
104
112
  glossaryHash?: string | undefined;
105
113
  external?: boolean | undefined;
114
+ manual?: boolean | undefined;
106
115
  }, {
107
116
  sourceHash?: string | undefined;
108
117
  fromMemory?: boolean | undefined;
@@ -110,6 +119,7 @@ export declare const lockfileDataSchema: z.ZodObject<{
110
119
  pseudo?: boolean | undefined;
111
120
  glossaryHash?: string | undefined;
112
121
  external?: boolean | undefined;
122
+ manual?: boolean | undefined;
113
123
  }>>>;
114
124
  }, "strip", z.ZodTypeAny, {
115
125
  sourceHash: string;
@@ -122,6 +132,7 @@ export declare const lockfileDataSchema: z.ZodObject<{
122
132
  pseudo?: boolean | undefined;
123
133
  glossaryHash?: string | undefined;
124
134
  external?: boolean | undefined;
135
+ manual?: boolean | undefined;
125
136
  }> | undefined;
126
137
  }, {
127
138
  sourceHash: string;
@@ -134,6 +145,7 @@ export declare const lockfileDataSchema: z.ZodObject<{
134
145
  pseudo?: boolean | undefined;
135
146
  glossaryHash?: string | undefined;
136
147
  external?: boolean | undefined;
148
+ manual?: boolean | undefined;
137
149
  }> | undefined;
138
150
  }>>;
139
151
  }, "strip", z.ZodTypeAny, {
@@ -148,6 +160,7 @@ export declare const lockfileDataSchema: z.ZodObject<{
148
160
  pseudo?: boolean | undefined;
149
161
  glossaryHash?: string | undefined;
150
162
  external?: boolean | undefined;
163
+ manual?: boolean | undefined;
151
164
  }> | undefined;
152
165
  }>;
153
166
  projectId: string;
@@ -165,6 +178,7 @@ export declare const lockfileDataSchema: z.ZodObject<{
165
178
  pseudo?: boolean | undefined;
166
179
  glossaryHash?: string | undefined;
167
180
  external?: boolean | undefined;
181
+ manual?: boolean | undefined;
168
182
  }> | undefined;
169
183
  }>;
170
184
  projectId: string;
@@ -32,6 +32,8 @@ exports.lockfileEntrySchema = zod_1.z.object({
32
32
  * catalogue), not from Babelize: glossary edits don't overwrite it.
33
33
  */
34
34
  external: zod_1.z.boolean().optional(),
35
+ /** Edited in the dashboard (a translation override), not translated by Babelize. */
36
+ manual: zod_1.z.boolean().optional(),
35
37
  })).optional(),
36
38
  });
37
39
  // ─────────────────────────────────────────────────────────────
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Translation overrides: translations a person edited in the dashboard.
3
+ *
4
+ * They win over the lockfile, translation memory and the translator, on every
5
+ * run, for as long as the source text is the one that was edited. When the
6
+ * source text changes the edit no longer fits: it is not applied, the key is
7
+ * translated as usual, and the run reports it as replaced.
8
+ */
9
+ import type { LocaleData } from '../../runtime/types';
10
+ export interface TranslationOverride {
11
+ key: string;
12
+ locale: string;
13
+ text: string;
14
+ /** The source text the edit was made against. */
15
+ sourceText: string;
16
+ }
17
+ export interface OverrideReport {
18
+ /** Edits written into this run's locale files. */
19
+ applied: Array<{
20
+ key: string;
21
+ locale: string;
22
+ }>;
23
+ /** Edits whose source text has changed since: not applied, translated as usual. */
24
+ replaced: Array<{
25
+ key: string;
26
+ locale: string;
27
+ text: string;
28
+ sourceText: string;
29
+ }>;
30
+ }
31
+ /** Set `key` in locale data: a literal flat key when the file has one, else a nested path. */
32
+ export declare function setLocaleValue(data: LocaleData, key: string, value: string): void;
33
+ /**
34
+ * Apply edits to the run's locale data in place. `source` is the flat source
35
+ * locale (key → text). Edits for a key the run no longer has, or a locale it
36
+ * doesn't write, are left alone (kept for when they come back).
37
+ */
38
+ export declare function applyTranslationOverrides(locales: Array<{
39
+ locale: string;
40
+ data: LocaleData;
41
+ }>, source: Record<string, string>, overrides: TranslationOverride[]): OverrideReport;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ /**
3
+ * Translation overrides: translations a person edited in the dashboard.
4
+ *
5
+ * They win over the lockfile, translation memory and the translator, on every
6
+ * run, for as long as the source text is the one that was edited. When the
7
+ * source text changes the edit no longer fits: it is not applied, the key is
8
+ * translated as usual, and the run reports it as replaced.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.setLocaleValue = setLocaleValue;
12
+ exports.applyTranslationOverrides = applyTranslationOverrides;
13
+ /** Set `key` in locale data: a literal flat key when the file has one, else a nested path. */
14
+ function setLocaleValue(data, key, value) {
15
+ if (typeof data[key] === 'string' || !key.includes('.')) {
16
+ data[key] = value;
17
+ return;
18
+ }
19
+ const parts = key.split('.');
20
+ let current = data;
21
+ for (let i = 0; i < parts.length - 1; i++) {
22
+ const part = parts[i];
23
+ if (typeof current[part] !== 'object' || current[part] === null)
24
+ current[part] = {};
25
+ current = current[part];
26
+ }
27
+ current[parts[parts.length - 1]] = value;
28
+ }
29
+ /**
30
+ * Apply edits to the run's locale data in place. `source` is the flat source
31
+ * locale (key → text). Edits for a key the run no longer has, or a locale it
32
+ * doesn't write, are left alone (kept for when they come back).
33
+ */
34
+ function applyTranslationOverrides(locales, source, overrides) {
35
+ const report = { applied: [], replaced: [] };
36
+ const byLocale = new Map(locales.map((l) => [l.locale, l]));
37
+ for (const o of overrides) {
38
+ const target = byLocale.get(o.locale);
39
+ const current = source[o.key];
40
+ if (!target || current === undefined)
41
+ continue;
42
+ if (current !== o.sourceText) {
43
+ report.replaced.push({ key: o.key, locale: o.locale, text: o.text, sourceText: o.sourceText });
44
+ continue;
45
+ }
46
+ setLocaleValue(target.data, o.key, o.text);
47
+ report.applied.push({ key: o.key, locale: o.locale });
48
+ }
49
+ return report;
50
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A run's translation budget: the most (key, locale) pairs it may send to the
3
+ * translator (the caller's credit budget, see EngineConfigV2.maxTranslations).
4
+ *
5
+ * Locales are translated concurrently, so the budget is granted in slot order
6
+ * (the locale's position in the run) rather than first come, first served:
7
+ * each locale claims what it needs once its cache and translation-memory
8
+ * lookups are done, and a claim is only settled after every earlier slot has
9
+ * claimed. The same project and budget therefore always translate the same
10
+ * pairs: earlier languages are completed first, keys in source order.
11
+ *
12
+ * Every slot must claim exactly once (0 when it has nothing to send), or the
13
+ * slots after it wait forever.
14
+ */
15
+ export declare class TranslationBudget {
16
+ private remaining;
17
+ private nextSlot;
18
+ private readonly pending;
19
+ constructor(limit: number);
20
+ /** Resolves with how many of `wanted` pairs slot `slot` may translate. */
21
+ claim(slot: number, wanted: number): Promise<number>;
22
+ private settle;
23
+ }
24
+ /** A `maxTranslations` value that caps the run: a finite number >= 0. */
25
+ export declare function isTranslationCap(max: number | undefined): max is number;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TranslationBudget = void 0;
4
+ exports.isTranslationCap = isTranslationCap;
5
+ /**
6
+ * A run's translation budget: the most (key, locale) pairs it may send to the
7
+ * translator (the caller's credit budget, see EngineConfigV2.maxTranslations).
8
+ *
9
+ * Locales are translated concurrently, so the budget is granted in slot order
10
+ * (the locale's position in the run) rather than first come, first served:
11
+ * each locale claims what it needs once its cache and translation-memory
12
+ * lookups are done, and a claim is only settled after every earlier slot has
13
+ * claimed. The same project and budget therefore always translate the same
14
+ * pairs: earlier languages are completed first, keys in source order.
15
+ *
16
+ * Every slot must claim exactly once (0 when it has nothing to send), or the
17
+ * slots after it wait forever.
18
+ */
19
+ class TranslationBudget {
20
+ constructor(limit) {
21
+ this.nextSlot = 0;
22
+ this.pending = new Map();
23
+ this.remaining = Math.max(0, Math.floor(limit));
24
+ }
25
+ /** Resolves with how many of `wanted` pairs slot `slot` may translate. */
26
+ claim(slot, wanted) {
27
+ return new Promise((resolve) => {
28
+ this.pending.set(slot, { wanted: Math.max(0, Math.floor(wanted)), resolve });
29
+ this.settle();
30
+ });
31
+ }
32
+ settle() {
33
+ for (let next = this.pending.get(this.nextSlot); next; next = this.pending.get(this.nextSlot)) {
34
+ this.pending.delete(this.nextSlot);
35
+ this.nextSlot++;
36
+ const granted = Math.min(next.wanted, this.remaining);
37
+ this.remaining -= granted;
38
+ next.resolve(granted);
39
+ }
40
+ }
41
+ }
42
+ exports.TranslationBudget = TranslationBudget;
43
+ /** A `maxTranslations` value that caps the run: a finite number >= 0. */
44
+ function isTranslationCap(max) {
45
+ return typeof max === 'number' && Number.isFinite(max) && max >= 0;
46
+ }
@@ -49,6 +49,13 @@ interface NumberSlot {
49
49
  * instead of showing it, and the branch is still exact.
50
50
  */
51
51
  mayBeSpelledOut: boolean;
52
+ /**
53
+ * Words for "one" in the target language, when this slot is the number 1 in
54
+ * a branch that also covers other numbers (French "one" is 0 and 1). The
55
+ * translator often writes "Vous avez un article" for "You have 1 item"; the
56
+ * word is put back as the count, which keeps the branch right for 0 too.
57
+ */
58
+ oneWords?: string[];
52
59
  }
53
60
  export interface BranchUnit {
54
61
  /** Unique within the plan. */
@@ -54,6 +54,21 @@ const CANDIDATES = (() => {
54
54
  const ARG_SENTINEL = /\uE001(\d+)\uE001/g;
55
55
  const POUND_SENTINEL = '\uE002';
56
56
  const TOKEN_PATTERN = /\[\s*#\s*VAR_\d+\s*#\s*\]/gi;
57
+ /** Spelled-out "one" (as an article or numeral) in languages whose "one" category has several numbers. */
58
+ const ONE_WORDS = {
59
+ fr: ['un', 'une'],
60
+ es: ['un', 'una', 'uno'],
61
+ pt: ['um', 'uma'],
62
+ it: ['un', 'uno', 'una'],
63
+ ca: ['un', 'una'],
64
+ ro: ['un', 'o'],
65
+ hi: ['एक'],
66
+ bn: ['এক', 'একটি'],
67
+ gu: ['એક'],
68
+ mr: ['एक'],
69
+ fa: ['یک'],
70
+ hy: ['մեկ'],
71
+ };
57
72
  class PlanError extends Error {
58
73
  }
59
74
  /** Plural categories of a locale, CLDR order. */
@@ -218,7 +233,8 @@ function makeUnit(nodes, path, state) {
218
233
  used.add(pick);
219
234
  // A plural added over a plain count must show it in every branch (compareIcuMessages), so no spelling out there.
220
235
  const mayBeSpelledOut = numbers.length === 1 && !(state.options.pluralizeArgs ?? []).includes(occ.owner.name);
221
- slot = { text: pick, count: 0, restore: occ.restore, label: occ.label, mayBeSpelledOut };
236
+ const oneWords = pick === '1' && !mayBeSpelledOut ? ONE_WORDS[state.targetLocale.split(/[-_]/)[0].toLowerCase()] : undefined;
237
+ slot = { text: pick, count: 0, restore: occ.restore, label: occ.label, mayBeSpelledOut, ...(oneWords ? { oneWords } : {}) };
222
238
  slotsByKind.set(kindKey, slot);
223
239
  }
224
240
  slot.count++;
@@ -287,10 +303,20 @@ function restoreNumbers(translated, slots) {
287
303
  return slot.restore;
288
304
  });
289
305
  }).join('');
306
+ let text = out;
290
307
  for (const slot of slots) {
291
- const n = found.get(slot) ?? 0;
308
+ let n = found.get(slot) ?? 0;
292
309
  if (n === 0 && slot.mayBeSpelledOut)
293
310
  continue;
311
+ if (n === 0 && slot.count === 1 && slot.oneWords) {
312
+ // "Vous avez un article" → "Vous avez # article", when the word is there exactly once.
313
+ const word = new RegExp(`(?<![\\p{L}\\p{M}])(?:${slot.oneWords.join('|')})(?![\\p{L}\\p{M}])`, 'giu');
314
+ const hits = text.match(word) ?? [];
315
+ if (hits.length === 1) {
316
+ text = text.replace(word, slot.restore);
317
+ n = 1;
318
+ }
319
+ }
294
320
  if (n !== slot.count) {
295
321
  return {
296
322
  problem: n === 0
@@ -299,7 +325,7 @@ function restoreNumbers(translated, slots) {
299
325
  };
300
326
  }
301
327
  }
302
- return { text: out };
328
+ return { text };
303
329
  }
304
330
  /** ICU-escape literal text: apostrophes before syntax, and `{`, `}` (and `#` in a plural) quoted. */
305
331
  function escapeIcuText(text, inPlural) {
@@ -32,6 +32,13 @@ export interface TranslateOptions {
32
32
  progress?: {
33
33
  freshTranslations: number;
34
34
  };
35
+ /**
36
+ * Most (key, locale) pairs to send to the translator this call (the run's
37
+ * credit budget). Cache, TM and passthrough reuse don't count. Locales are
38
+ * filled in `targetLocales` order, keys in source order; the rest are
39
+ * returned as `skippedForBudget`. Undefined or negative: no cap.
40
+ */
41
+ maxTranslations?: number;
35
42
  }
36
43
  export interface LoadedTranslationRules {
37
44
  glossary: GlossaryRules;
@@ -59,6 +66,10 @@ export declare function translateLocales(workspace: WorkspaceInfo, sourceLocale:
59
66
  error: string;
60
67
  }[];
61
68
  failures: TranslationFailure[];
69
+ skippedForBudget: {
70
+ key: string;
71
+ locale: string;
72
+ }[];
62
73
  };
63
74
  stats: {
64
75
  tmHits: number;
@@ -24,6 +24,7 @@ const translation_sanitizer_1 = require("../../utils/translation-sanitizer");
24
24
  const glossary_match_1 = require("../../utils/glossary-match");
25
25
  const icu_spans_1 = require("../../utils/icu-spans");
26
26
  const icu_branches_1 = require("./icu-branches");
27
+ const budget_1 = require("./budget");
27
28
  const BATCH_SIZE = 15;
28
29
  /** QA note on keys the backup (NLLB) translator produced. */
29
30
  exports.FALLBACK_QA_WARNING = 'translated with the backup translator; will be retried with the AI translator next run';
@@ -214,6 +215,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
214
215
  const locales = [];
215
216
  const errors = [];
216
217
  const failures = [];
218
+ const skippedForBudget = [];
217
219
  let tmHitsTotal = 0;
218
220
  const perLocaleMetadata = {};
219
221
  // Ensure locale output directories exist for all target locales
@@ -248,7 +250,23 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
248
250
  }
249
251
  const localeTasks = targetLocales
250
252
  .filter((l) => l !== sourceLocale.locale && (0, language_codes_1.isSupported)(l));
251
- const { results: localeResults, errors: localeErrors } = await (0, concurrent_map_1.concurrentMap)(localeTasks, async (targetLocale) => {
253
+ // The run's translation budget, granted in locale order (see TranslationBudget).
254
+ const budget = (0, budget_1.isTranslationCap)(options.maxTranslations) ? new budget_1.TranslationBudget(options.maxTranslations) : null;
255
+ // Every locale claims its share of the budget exactly once, even when it has
256
+ // nothing to send or fails, or the locales after it never get theirs.
257
+ const withBudget = (fn) => async (targetLocale, slot) => {
258
+ if (!budget)
259
+ return fn(targetLocale);
260
+ let claimed = false;
261
+ try {
262
+ return await fn(targetLocale, (wanted) => { claimed = true; return budget.claim(slot, wanted); });
263
+ }
264
+ finally {
265
+ if (!claimed)
266
+ void budget.claim(slot, 0);
267
+ }
268
+ };
269
+ const { results: localeResults, errors: localeErrors } = await (0, concurrent_map_1.concurrentMap)(localeTasks, withBudget(async (targetLocale, claimBudget) => {
252
270
  const targetLangCode = (0, language_codes_1.toFlores)(targetLocale);
253
271
  const localePaths = (0, file_utils_1.localeFilePath)(workspace.path, workspace.publicDir, workspace.localeFormat, targetLocale);
254
272
  const targetPath = localePaths.file;
@@ -269,6 +287,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
269
287
  tmHits: 0,
270
288
  metadata: {},
271
289
  failures: {},
290
+ skippedForBudget: [],
272
291
  error: null,
273
292
  };
274
293
  }
@@ -276,7 +295,7 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
276
295
  // LLM translators (BYOK, or an injected one such as Babelize-hosted)
277
296
  // take the brand voice in the prompt; NLLB output is polished after.
278
297
  const hasBrandVoiceInPrompt = !!((byokConfig || (providers.translator && !providers.translator.plainText)) && brandVoice);
279
- const translationResult = await translateEntries(entriesToTranslate, sourceLangCode, targetLangCode, projectId, onProgress, glossary, targetLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, hasBrandVoiceInPrompt ? (brandVoice || undefined) : undefined, dryRun, forceTranslation, providers, rulesComplete, options);
298
+ const translationResult = await translateEntries(entriesToTranslate, sourceLangCode, targetLangCode, projectId, onProgress, glossary, targetLocale, cacheProvider, byokConfig, contextMap, extractionContextMap, hasBrandVoiceInPrompt ? (brandVoice || undefined) : undefined, dryRun, forceTranslation, providers, rulesComplete, options, claimBudget);
280
299
  const entriesResult = translationResult.result;
281
300
  const batchStats = translationResult.stats;
282
301
  const batchMetadata = translationResult.metadata;
@@ -284,10 +303,13 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
284
303
  if (Object.keys(entriesResult).length === 0 && Object.keys(entriesToTranslate).length > 0) {
285
304
  // Nothing translated: write no file. Every key is in keyFailures with
286
305
  // its reason, so the caller can report exactly what went wrong.
287
- logger_1.engineLogger.error(`No strings could be translated for locale "${targetLocale}"`, {
288
- locale: targetLocale, sourceKeys: Object.keys(entriesToTranslate).length,
289
- });
290
- return { locale: targetLocale, file: null, tmHits: batchStats.tmHits, metadata: batchMetadata, failures: keyFailures, error: null };
306
+ // (Not an error when the run's budget simply didn't reach this locale.)
307
+ if (Object.keys(keyFailures).length > 0 || translationResult.skippedForBudget.length === 0) {
308
+ logger_1.engineLogger.error(`No strings could be translated for locale "${targetLocale}"`, {
309
+ locale: targetLocale, sourceKeys: Object.keys(entriesToTranslate).length,
310
+ });
311
+ }
312
+ return { locale: targetLocale, file: null, tmHits: batchStats.tmHits, metadata: batchMetadata, failures: keyFailures, skippedForBudget: translationResult.skippedForBudget, error: null };
291
313
  }
292
314
  let translatedEntries = entriesResult;
293
315
  // Post-hoc brand voice polish only for the NLLB service; LLM
@@ -355,15 +377,18 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
355
377
  tmHits: batchStats.tmHits,
356
378
  metadata: batchMetadata,
357
379
  failures: keyFailures,
380
+ skippedForBudget: translationResult.skippedForBudget,
358
381
  error: null,
359
382
  };
360
- }, TRANSLATE_CONCURRENCY);
383
+ }), TRANSLATE_CONCURRENCY);
361
384
  for (const r of localeResults) {
362
385
  if (!r)
363
386
  continue;
364
387
  for (const [key, f] of Object.entries(r.failures ?? {})) {
365
388
  failures.push({ locale: r.locale, key, source: flatSource[key] ?? '', ...f });
366
389
  }
390
+ for (const key of r.skippedForBudget ?? [])
391
+ skippedForBudget.push({ key, locale: r.locale });
367
392
  if (r.error) {
368
393
  errors.push({ locale: r.locale, error: r.error });
369
394
  }
@@ -385,13 +410,19 @@ async function translateLocales(workspace, sourceLocale, targetLocales, forceTra
385
410
  if (byokConfig && (errors.length > 0 || localeErrors.length > 0)) {
386
411
  throw localeErrors.length > 0 ? localeErrors[0] : new Error(errors[0].error);
387
412
  }
413
+ if (skippedForBudget.length > 0) {
414
+ logger_1.engineLogger.warn('Translation budget reached; remaining strings are left for the next run', {
415
+ maxTranslations: options.maxTranslations,
416
+ skipped: skippedForBudget.length,
417
+ });
418
+ }
388
419
  return {
389
- translation: { locales, errors, failures },
420
+ translation: { locales, errors, failures, skippedForBudget },
390
421
  stats: { tmHits: tmHitsTotal },
391
422
  metadata: perLocaleMetadata
392
423
  };
393
424
  }
394
- async function translateEntries(entries, sourceLang, targetLang, projectId, onProgress, glossary = glossary_1.EMPTY_GLOSSARY, targetLocale = '', cacheProvider, byokConfig, contextMap, extractionContextMap, brandVoice, dryRun = false, forceTranslation = false, providers = {}, rulesComplete = true, options = {}) {
425
+ async function translateEntries(entries, sourceLang, targetLang, projectId, onProgress, glossary = glossary_1.EMPTY_GLOSSARY, targetLocale = '', cacheProvider, byokConfig, contextMap, extractionContextMap, brandVoice, dryRun = false, forceTranslation = false, providers = {}, rulesComplete = true, options = {}, claimBudget) {
395
426
  const result = {};
396
427
  const metadata = {};
397
428
  const keys = Object.keys(entries);
@@ -546,6 +577,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
546
577
  });
547
578
  }
548
579
  const failures = {};
580
+ const skippedForBudget = [];
549
581
  // LLM providers get structure-aware prompts and repair attempts. The NLLB
550
582
  // service translates plain text only: ICU messages go branch by branch
551
583
  // (./icu-branches), and mixed-script text fails up front.
@@ -618,6 +650,14 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
618
650
  : undefined,
619
651
  });
620
652
  }
653
+ // 1b. The run's budget: only as many keys as it covers go to the
654
+ // translator (in source order). The rest get no value, like a failed
655
+ // key: not in the locale file or lockfile, so the next run retries them.
656
+ if (claimBudget) {
657
+ const granted = await claimBudget(prepared.length);
658
+ for (const entry of prepared.splice(granted))
659
+ skippedForBudget.push(entry.key);
660
+ }
621
661
  // 2. Translate in batches; judge every key independently.
622
662
  const outcomes = new Map();
623
663
  const judge = (entry, keyResult, nllb = !isLlm) => {
@@ -970,7 +1010,7 @@ async function translateEntries(entries, sourceLang, targetLang, projectId, onPr
970
1010
  meta.qa = [...(meta.qa ?? []), ...notes];
971
1011
  metadata[key] = meta;
972
1012
  }
973
- return { result, stats: { tmHits }, metadata, failures };
1013
+ return { result, stats: { tmHits }, metadata, failures, skippedForBudget };
974
1014
  }
975
1015
  /**
976
1016
  * Persist translations to the project owner's TM. Never throws: a TM failure