@verbatra/sdk 0.5.0-next.3 → 0.5.0

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.
package/dist/index.cjs CHANGED
@@ -145,6 +145,24 @@ async function atomicWrite(path, data) {
145
145
  throw error;
146
146
  }
147
147
  }
148
+ async function createExclusive(path$1, data) {
149
+ await promises.mkdir(path.dirname(path$1), { recursive: true });
150
+ let handle;
151
+ try {
152
+ handle = await promises.open(path$1, "wx");
153
+ } catch (error) {
154
+ if (error.code === "EEXIST") {
155
+ return false;
156
+ }
157
+ throw error;
158
+ }
159
+ try {
160
+ await handle.writeFile(data, "utf8");
161
+ } finally {
162
+ await handle.close();
163
+ }
164
+ return true;
165
+ }
148
166
  var defaultFs = {
149
167
  async fileExists(path) {
150
168
  try {
@@ -157,7 +175,11 @@ var defaultFs = {
157
175
  readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
158
176
  readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
159
177
  writeFile: (path, data) => atomicWrite(path, data),
160
- writeBytes: (path, data) => atomicWrite(path, data)
178
+ writeBytes: (path, data) => atomicWrite(path, data),
179
+ createExclusive: (path, data) => createExclusive(path, data),
180
+ deleteFile: async (path) => {
181
+ await promises.rm(path, { force: true });
182
+ }
161
183
  };
162
184
  var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
163
185
  var glossaryRecordSchema = zod.z.record(zod.z.string(), zod.z.string());
@@ -346,29 +368,18 @@ var TIMEOUT_STATUS = 408;
346
368
  var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
347
369
  var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
348
370
  "RateLimitError",
349
- // openai, @anthropic-ai/sdk
350
371
  "TooManyRequestsError"
351
- // deepl-node
352
372
  ]);
353
373
  var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
354
374
  "AuthenticationError",
355
- // openai, @anthropic-ai/sdk (401)
356
375
  "PermissionDeniedError",
357
- // openai, @anthropic-ai/sdk (403)
358
376
  "AuthorizationError"
359
- // deepl-node
360
377
  ]);
361
378
  var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
362
379
  "APIConnectionTimeoutError",
363
- // openai, @anthropic-ai/sdk
364
- // deepl-node has no distinct timeout class; ConnectionError is its only network-failure
365
- // class, so it is folded into TIMEOUT rather than the PROVIDER_ERROR fallback.
366
380
  "ConnectionError"
367
381
  ]);
368
- var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set([
369
- "APIUserAbortError"
370
- // openai, @anthropic-ai/sdk
371
- ]);
382
+ var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set(["APIUserAbortError"]);
372
383
  function getErrorStatus(error) {
373
384
  if (typeof error !== "object" || error === null || !("status" in error)) {
374
385
  return void 0;
@@ -422,7 +433,6 @@ function isAbortError(error, signal) {
422
433
  }
423
434
  var REDACTED = "[REDACTED]";
424
435
  var KEY_PATTERNS = [
425
- // The `\b` anchors `sk-` to a word start so hyphenated words like "risk-" or "task-" pass through.
426
436
  /\bsk-[A-Za-z0-9_-]{8,}/g,
427
437
  /AIza[0-9A-Za-z_-]{35}/g,
428
438
  /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?::fx)?/g
@@ -444,6 +454,10 @@ var ProviderError = class extends Error {
444
454
  /** The stable {@link ProviderErrorCode} for this failure; branch on this, not the message. */
445
455
  code;
446
456
  /**
457
+ * Redacts the message as a defense-in-depth backstop before storing it. The redaction runs with an
458
+ * empty secret argument so the ANTHROPIC_API_KEY environment default is not re-applied, keeping
459
+ * this generic error decoupled from any one provider's environment variable.
460
+ *
447
461
  * @param code - The stable failure code.
448
462
  * @param message - A fixed, safe message; callers must never pass key, SDK, or request-derived text.
449
463
  */
@@ -502,6 +516,76 @@ function validateRequest(request) {
502
516
  }
503
517
  return parsed.data;
504
518
  }
519
+ var LENGTH_RATIO_MIN = 0.35;
520
+ var LENGTH_RATIO_MAX = 3;
521
+ var LENGTH_RATIO_MIN_SOURCE_LENGTH = 12;
522
+ var UNICODE_LETTER = /\p{L}/u;
523
+ var DEGRADATION_NOTICE_CODES = /* @__PURE__ */ new Set([
524
+ "FORMALITY_DOWNGRADED",
525
+ "GLOSSARY_IGNORED"
526
+ ]);
527
+ function isLengthRatioOutlier(sourceValue, translatedValue) {
528
+ const trimmedSource = sourceValue.trim();
529
+ if (trimmedSource.length < LENGTH_RATIO_MIN_SOURCE_LENGTH) {
530
+ return false;
531
+ }
532
+ const ratio = translatedValue.trim().length / trimmedSource.length;
533
+ return ratio < LENGTH_RATIO_MIN || ratio > LENGTH_RATIO_MAX;
534
+ }
535
+ function isEqualsSource(input) {
536
+ const trimmedSource = input.sourceValue.trim();
537
+ const trimmedTranslated = input.translatedValue.trim();
538
+ return trimmedTranslated === trimmedSource && input.targetLocale !== input.sourceLocale && UNICODE_LETTER.test(trimmedSource);
539
+ }
540
+ function isGlossaryTermMissed(input) {
541
+ const glossary = input.glossary;
542
+ if (glossary === void 0 || Object.keys(glossary).length === 0) {
543
+ return false;
544
+ }
545
+ const sourceLower = input.sourceValue.toLowerCase();
546
+ const translatedLower = input.translatedValue.toLowerCase();
547
+ for (const [sourceTerm, targetTerm] of Object.entries(glossary)) {
548
+ const sourceHit = sourceLower.includes(sourceTerm.toLowerCase());
549
+ const targetHit = translatedLower.includes(targetTerm.toLowerCase());
550
+ if (sourceHit && !targetHit) {
551
+ return true;
552
+ }
553
+ }
554
+ return false;
555
+ }
556
+ function isIntegrityReordered(integrity) {
557
+ return integrity.matches && integrity.reordered;
558
+ }
559
+ function computeReviewFlags(input) {
560
+ const reasons = [];
561
+ if (isLengthRatioOutlier(input.sourceValue, input.translatedValue)) {
562
+ reasons.push("LENGTH_RATIO_OUTLIER");
563
+ }
564
+ if (isEqualsSource(input)) {
565
+ reasons.push("EQUALS_SOURCE");
566
+ }
567
+ if (isGlossaryTermMissed(input)) {
568
+ reasons.push("GLOSSARY_TERM_MISSED");
569
+ }
570
+ if (isIntegrityReordered(input.integrity)) {
571
+ reasons.push("INTEGRITY_REORDERED");
572
+ }
573
+ return reasons.length > 0 ? { status: "review", reasons } : void 0;
574
+ }
575
+ function applyProviderDegraded(reviewFlags, notices, acceptedKeys) {
576
+ if (!notices.some((notice) => DEGRADATION_NOTICE_CODES.has(notice.code))) {
577
+ return reviewFlags;
578
+ }
579
+ const next = new Map(reviewFlags);
580
+ for (const key of acceptedKeys) {
581
+ const existing = next.get(key);
582
+ next.set(key, {
583
+ status: "review",
584
+ reasons: existing !== void 0 ? [...existing.reasons, "PROVIDER_DEGRADED"] : ["PROVIDER_DEGRADED"]
585
+ });
586
+ }
587
+ return next;
588
+ }
505
589
  function toIntegrityInputs(entries, values) {
506
590
  const inputs = [];
507
591
  for (const entry of entries) {
@@ -597,7 +681,33 @@ async function runLlmTranslation(request, mechanism) {
597
681
  request.extractPlaceholders,
598
682
  request.comparePlaceholders
599
683
  );
600
- return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
684
+ const notices = [];
685
+ const reviewFlags = applyProviderDegraded(buildReviewFlags(data, values, integrity), notices, [
686
+ ...values.keys()
687
+ ]);
688
+ return usage === void 0 ? { values, integrity, notices, reviewFlags } : { values, integrity, usage, notices, reviewFlags };
689
+ }
690
+ function buildReviewFlags(data, values, integrity) {
691
+ const reviewFlags = /* @__PURE__ */ new Map();
692
+ for (const entry of data.entries) {
693
+ const translatedValue = values.get(entry.key);
694
+ const entryIntegrity = integrity.get(entry.key);
695
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
696
+ continue;
697
+ }
698
+ const flag = computeReviewFlags({
699
+ sourceValue: entry.value,
700
+ translatedValue,
701
+ sourceLocale: data.sourceLocale,
702
+ targetLocale: data.targetLocale,
703
+ integrity: entryIntegrity,
704
+ glossary: data.glossary
705
+ });
706
+ if (flag !== void 0) {
707
+ reviewFlags.set(entry.key, flag);
708
+ }
709
+ }
710
+ return reviewFlags;
601
711
  }
602
712
  async function requestTranslations(mechanism, data, signal) {
603
713
  const payloadJson = JSON.stringify(buildDataPayload(data));
@@ -915,9 +1025,6 @@ function createDeepLProvider(config, deps = {}) {
915
1025
  return {
916
1026
  id: PROVIDER_ID2,
917
1027
  kind: "machine-translation",
918
- // Honest capability signal: DeepL only ever applies a pre-created native glossary id, never a
919
- // generic term map (see buildTranslateOptions). Without one configured, a supplied term map is
920
- // silently ignored and surfaces only as a GLOSSARY_IGNORED notice.
921
1028
  supportsGlossary: validConfig.glossaryId !== void 0,
922
1029
  translateBatch: (request) => translate(bundle, validConfig, request)
923
1030
  };
@@ -952,7 +1059,34 @@ async function translate(bundle, config, request) {
952
1059
  if (unprotectable.length > 0) {
953
1060
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
954
1061
  }
955
- return { values, integrity, notices };
1062
+ const reviewFlags = applyProviderDegraded(
1063
+ buildReviewFlags2(protectable, data, values, integrity, request.glossary),
1064
+ notices,
1065
+ [...values.keys()]
1066
+ );
1067
+ return { values, integrity, notices, reviewFlags };
1068
+ }
1069
+ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1070
+ const reviewFlags = /* @__PURE__ */ new Map();
1071
+ for (const entry of protectable) {
1072
+ const translatedValue = values.get(entry.key);
1073
+ const entryIntegrity = integrity.get(entry.key);
1074
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
1075
+ continue;
1076
+ }
1077
+ const flag = computeReviewFlags({
1078
+ sourceValue: entry.value,
1079
+ translatedValue,
1080
+ sourceLocale: data.sourceLocale,
1081
+ targetLocale: data.targetLocale,
1082
+ integrity: entryIntegrity,
1083
+ glossary
1084
+ });
1085
+ if (flag !== void 0) {
1086
+ reviewFlags.set(entry.key, flag);
1087
+ }
1088
+ }
1089
+ return reviewFlags;
956
1090
  }
957
1091
  async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
958
1092
  if (protectable.length === 0) {
@@ -1002,14 +1136,14 @@ function throwAbort(signal) {
1002
1136
  throw new DOMException("This operation was aborted.", "AbortError");
1003
1137
  }
1004
1138
  function delay(ms, signal) {
1005
- return new Promise((resolve7) => {
1139
+ return new Promise((resolve9) => {
1006
1140
  const onAbort = () => {
1007
1141
  clearTimeout(timer);
1008
- resolve7();
1142
+ resolve9();
1009
1143
  };
1010
1144
  const timer = setTimeout(() => {
1011
1145
  signal?.removeEventListener("abort", onAbort);
1012
- resolve7();
1146
+ resolve9();
1013
1147
  }, ms);
1014
1148
  signal?.addEventListener("abort", onAbort, { once: true });
1015
1149
  });
@@ -1057,12 +1191,10 @@ var TYPE_MAP = {
1057
1191
  object: "OBJECT"
1058
1192
  };
1059
1193
  var HANDLED_KEYWORDS = /* @__PURE__ */ new Set([
1060
- // transformed or recursed
1061
1194
  "type",
1062
1195
  "required",
1063
1196
  "properties",
1064
1197
  "items",
1065
- // deliberately dropped (not part of Google's Schema dialect)
1066
1198
  "$schema",
1067
1199
  "additionalProperties"
1068
1200
  ]);
@@ -1456,6 +1588,7 @@ function buildProvider(config) {
1456
1588
 
1457
1589
  // src/config/schema.ts
1458
1590
  var DEFAULT_MAX_BATCH_SIZE = 50;
1591
+ var DEFAULT_BUDGET_BEHAVIOR = "warn";
1459
1592
  function findCaseInsensitiveDuplicate(locales) {
1460
1593
  const seen = /* @__PURE__ */ new Set();
1461
1594
  for (const locale of locales) {
@@ -1504,7 +1637,23 @@ var verbatraConfigSchema = zod.z.strictObject({
1504
1637
  * be a positive integer (zero, negative, or non-integer is rejected, never coerced). When absent,
1505
1638
  * {@link DEFAULT_MAX_BATCH_SIZE} applies.
1506
1639
  */
1507
- maxBatchSize: zod.z.number().int().positive().optional()
1640
+ maxBatchSize: zod.z.number().int().positive().optional(),
1641
+ /**
1642
+ * Optional whole-run token ceiling (input plus output tokens summed across every provider call, main
1643
+ * translation and plural generation alike, across all target locales). Checked after each completed
1644
+ * sub-batch, never mid-batch: the sub-batch whose completion crosses the ceiling is retained and
1645
+ * counted, since a call already in flight cannot be undone. Must be a positive integer. Config-only,
1646
+ * no CLI flag. Absent means no budget is enforced. Inert (never a false trip) against a token-less
1647
+ * provider such as DeepL, since it never reports usage to measure against the ceiling.
1648
+ */
1649
+ maxTokens: zod.z.number().int().positive().optional(),
1650
+ /**
1651
+ * What happens once `maxTokens` is reached: `"warn"` (default) flags it and lets the run continue
1652
+ * unchanged; `"stop"` withholds every not-yet-attempted key for the rest of the run (the current
1653
+ * locale's remaining candidates and every later locale's), retried automatically next run. Present
1654
+ * without `maxTokens` is accepted and has no effect. Never changes the command's exit code.
1655
+ */
1656
+ budgetBehavior: zod.z.enum(["warn", "stop"]).optional()
1508
1657
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
1509
1658
  message: "targetLocales must not include the source locale",
1510
1659
  path: ["targetLocales"]
@@ -1613,6 +1762,60 @@ async function loadConfig(options = {}) {
1613
1762
  const { config } = await loadConfigWithMeta(options);
1614
1763
  return config;
1615
1764
  }
1765
+ var LOCAL_DIR_NAME = ".verbatra-local";
1766
+ var LOCK_FILE_GUARD_STEM = "_lockfile";
1767
+ var DEFAULT_POLL_INTERVAL_MS = 100;
1768
+ var DEFAULT_ACQUIRE_TIMEOUT_MS = 10 * 6e4;
1769
+ function lockPath(cwd, stem) {
1770
+ return path.resolve(cwd, LOCAL_DIR_NAME, "locks", `${stem}.lock`);
1771
+ }
1772
+ function localeLockPath(cwd, locale) {
1773
+ return lockPath(cwd, locale);
1774
+ }
1775
+ function lockFileGuardPath(cwd) {
1776
+ return lockPath(cwd, LOCK_FILE_GUARD_STEM);
1777
+ }
1778
+ function sleep(ms) {
1779
+ return new Promise((res) => {
1780
+ setTimeout(res, ms);
1781
+ });
1782
+ }
1783
+ function lockPayload() {
1784
+ return JSON.stringify({ pid: process.pid, acquiredAt: (/* @__PURE__ */ new Date()).toISOString() });
1785
+ }
1786
+ async function acquireLock(path, fs, pollIntervalMs, deadline) {
1787
+ for (; ; ) {
1788
+ if (await fs.createExclusive(path, lockPayload())) {
1789
+ return;
1790
+ }
1791
+ if (Date.now() >= deadline) {
1792
+ throw new SdkError(
1793
+ "LOCK_CONTENDED",
1794
+ `Could not acquire the write lock at ${path}: another process may be holding it. If no verbatra process is currently running, this lock file was likely left behind by one that was killed; delete it and retry.`
1795
+ );
1796
+ }
1797
+ const jitter = Math.random() * pollIntervalMs;
1798
+ await sleep(pollIntervalMs + jitter);
1799
+ }
1800
+ }
1801
+ async function withFileLock(path, fs, fn, options) {
1802
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1803
+ const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
1804
+ await acquireLock(path, fs, pollIntervalMs, Date.now() + acquireTimeoutMs);
1805
+ try {
1806
+ return await fn();
1807
+ } finally {
1808
+ await fs.deleteFile(path);
1809
+ }
1810
+ }
1811
+ async function withLocaleWriteLock(cwd, locale, fs, fn, options = {}) {
1812
+ return withFileLock(localeLockPath(cwd, locale), fs, fn, options);
1813
+ }
1814
+ async function withLockFileGuard(cwd, fs, fn, options = {}) {
1815
+ return withFileLock(lockFileGuardPath(cwd), fs, fn, options);
1816
+ }
1817
+
1818
+ // src/lock/lock-file.ts
1616
1819
  var LOCK_FILE_NAME = "verbatra.lock.json";
1617
1820
  var CURRENT_VERSION = 1;
1618
1821
  var EMPTY_LOCK = { version: CURRENT_VERSION, locales: {} };
@@ -1624,8 +1827,7 @@ var lockFileSchema = zod.z.object({
1624
1827
  function lockFilePath(cwd) {
1625
1828
  return path.resolve(cwd, LOCK_FILE_NAME);
1626
1829
  }
1627
- async function readLockFile(path, fs) {
1628
- const read = await fs.readFileBounded(path, MAX_LOCK_FILE_BYTES);
1830
+ function parseLockFileRead(read, path) {
1629
1831
  if (read.kind === "missing") {
1630
1832
  return EMPTY_LOCK;
1631
1833
  }
@@ -1653,6 +1855,9 @@ async function readLockFile(path, fs) {
1653
1855
  }
1654
1856
  return result.data;
1655
1857
  }
1858
+ async function readLockFile(path, fs) {
1859
+ return parseLockFileRead(await fs.readFileBounded(path, MAX_LOCK_FILE_BYTES), path);
1860
+ }
1656
1861
  function baselineFor(lock, locale) {
1657
1862
  return new Map(Object.entries(lock.locales[locale] ?? {}));
1658
1863
  }
@@ -1668,14 +1873,30 @@ function byKey(a, b) {
1668
1873
  function sortRecord(record) {
1669
1874
  return Object.fromEntries(Object.entries(record).sort(byKey));
1670
1875
  }
1671
- async function writeLockFile(path, lock, fs) {
1876
+ function serializeLockFile(lock) {
1672
1877
  const locales = {};
1673
1878
  for (const [locale, entries] of Object.entries(lock.locales).sort(byKey)) {
1674
1879
  locales[locale] = sortRecord(entries);
1675
1880
  }
1676
1881
  const ordered = { version: lock.version, locales };
1677
- await fs.writeFile(path, `${JSON.stringify(ordered, null, 2)}
1678
- `);
1882
+ return `${JSON.stringify(ordered, null, 2)}
1883
+ `;
1884
+ }
1885
+ function applyLockLocalePatch(currentEntries, patch) {
1886
+ if (patch.mode === "replace") {
1887
+ return patch.entries;
1888
+ }
1889
+ return { ...currentEntries, ...patch.entries };
1890
+ }
1891
+ async function updateLockFileLocale(cwd, fs, locale, patch) {
1892
+ return withLockFileGuard(cwd, fs, async () => {
1893
+ const path = lockFilePath(cwd);
1894
+ const lock = parseLockFileRead(await fs.readFileBounded(path, MAX_LOCK_FILE_BYTES), path);
1895
+ const nextEntries = applyLockLocalePatch(lock.locales[locale], patch);
1896
+ const next = updateLockLocale(lock, locale, nextEntries);
1897
+ await fs.writeFile(path, serializeLockFile(next));
1898
+ return next;
1899
+ });
1679
1900
  }
1680
1901
  var VALID_EMPTY = { placeholders: [], isPlural: false, valid: true };
1681
1902
  var INVALID = { placeholders: [], isPlural: false, valid: false };
@@ -1937,10 +2158,68 @@ var AdapterError = class extends Error {
1937
2158
  };
1938
2159
  var MAX_DEPTH = 100;
1939
2160
  var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1940
- var jsonTreeSchema = zod.z.lazy(
1941
- () => zod.z.union([zod.z.string(), zod.z.record(zod.z.string(), jsonTreeSchema)])
1942
- );
1943
- var rootSchema = zod.z.record(zod.z.string(), jsonTreeSchema);
2161
+ var SENTINEL_ESCAPE = "\\u0001";
2162
+ function isJsonWhitespace(char) {
2163
+ return char === " " || char === " " || char === "\n" || char === "\r";
2164
+ }
2165
+ function endOfStringToken(content, openQuote) {
2166
+ let index = openQuote + 1;
2167
+ while (index < content.length) {
2168
+ const char = content[index];
2169
+ if (char === "\\") {
2170
+ index += 2;
2171
+ } else if (char === '"') {
2172
+ return index + 1;
2173
+ } else {
2174
+ index += 1;
2175
+ }
2176
+ }
2177
+ return index;
2178
+ }
2179
+ function isKeyToken(content, end) {
2180
+ let index = end;
2181
+ while (isJsonWhitespace(content[index])) {
2182
+ index += 1;
2183
+ }
2184
+ return content[index] === ":";
2185
+ }
2186
+ function prefixKeyToken(token) {
2187
+ if (token.includes(SENTINEL_ESCAPE)) {
2188
+ throw new AdapterError(
2189
+ "INVALID_STRUCTURE",
2190
+ "A key contains a reserved control-character escape."
2191
+ );
2192
+ }
2193
+ return `"${SENTINEL_ESCAPE}${token.slice(1)}`;
2194
+ }
2195
+ function prefixKeyTokens(content) {
2196
+ let out = "";
2197
+ let index = 0;
2198
+ while (index < content.length) {
2199
+ const openQuote = content.indexOf('"', index);
2200
+ if (openQuote === -1) {
2201
+ return out + content.slice(index);
2202
+ }
2203
+ const end = endOfStringToken(content, openQuote);
2204
+ const token = content.slice(openQuote, end);
2205
+ out += content.slice(index, openQuote);
2206
+ out += isKeyToken(content, end) ? prefixKeyToken(token) : token;
2207
+ index = end;
2208
+ }
2209
+ return out;
2210
+ }
2211
+ function childrenOf(node) {
2212
+ if (node instanceof Map) {
2213
+ return node.values();
2214
+ }
2215
+ if (Array.isArray(node)) {
2216
+ return node;
2217
+ }
2218
+ if (typeof node === "object" && node !== null) {
2219
+ return Object.values(node);
2220
+ }
2221
+ return null;
2222
+ }
1944
2223
  function assertWithinDepth(value, max) {
1945
2224
  const stack = [{ node: value, depth: 1 }];
1946
2225
  while (stack.length > 0) {
@@ -1948,42 +2227,118 @@ function assertWithinDepth(value, max) {
1948
2227
  if (top === void 0) {
1949
2228
  break;
1950
2229
  }
1951
- const { node, depth } = top;
1952
- if (typeof node !== "object" || node === null) {
2230
+ const children = childrenOf(top.node);
2231
+ if (children === null) {
1953
2232
  continue;
1954
2233
  }
1955
- if (depth > max) {
2234
+ if (top.depth > max) {
1956
2235
  throw new AdapterError("MAX_DEPTH_EXCEEDED", "The file nests objects too deeply.");
1957
2236
  }
1958
- for (const child of Object.values(node)) {
1959
- stack.push({ node: child, depth: depth + 1 });
2237
+ for (const child of children) {
2238
+ stack.push({ node: child, depth: top.depth + 1 });
1960
2239
  }
1961
2240
  }
1962
2241
  }
1963
- function assertJsonRecord(value) {
1964
- assertWithinDepth(value, MAX_DEPTH);
1965
- const result = rootSchema.safeParse(value);
1966
- if (!result.success) {
1967
- throw new AdapterError(
1968
- "INVALID_STRUCTURE",
1969
- "The file is not a valid object (expected nested objects of string values)."
1970
- );
2242
+ function toOrdered(node) {
2243
+ if (Array.isArray(node)) {
2244
+ return node.map((child) => toOrdered(child));
1971
2245
  }
1972
- return result.data;
2246
+ if (typeof node === "object" && node !== null) {
2247
+ const out = /* @__PURE__ */ new Map();
2248
+ for (const [key, child] of Object.entries(node)) {
2249
+ out.set(key.slice(1), toOrdered(child));
2250
+ }
2251
+ return out;
2252
+ }
2253
+ return node;
1973
2254
  }
1974
- function parseJsonObject(content) {
2255
+ function parseOrderedJson(content) {
2256
+ const prefixed = prefixKeyTokens(content);
1975
2257
  let parsed;
1976
2258
  try {
1977
- parsed = JSON.parse(content);
2259
+ parsed = JSON.parse(prefixed);
1978
2260
  } catch {
1979
2261
  throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
1980
2262
  }
1981
- return assertJsonRecord(parsed);
2263
+ assertWithinDepth(parsed, MAX_DEPTH);
2264
+ return toOrdered(parsed);
1982
2265
  }
1983
- function serializeJsonTree(tree) {
1984
- return `${JSON.stringify(tree, null, 2)}
2266
+ function printRecord(record, indent) {
2267
+ if (record.size === 0) {
2268
+ return "{}";
2269
+ }
2270
+ const childIndent = `${indent} `;
2271
+ const lines = [];
2272
+ for (const [key, child] of record) {
2273
+ lines.push(`${childIndent}${JSON.stringify(key)}: ${printValue(child, childIndent)}`);
2274
+ }
2275
+ return `{
2276
+ ${lines.join(",\n")}
2277
+ ${indent}}`;
2278
+ }
2279
+ function printArray(items, indent) {
2280
+ if (items.length === 0) {
2281
+ return "[]";
2282
+ }
2283
+ const childIndent = `${indent} `;
2284
+ const lines = items.map((item) => `${childIndent}${printValue(item, childIndent)}`);
2285
+ return `[
2286
+ ${lines.join(",\n")}
2287
+ ${indent}]`;
2288
+ }
2289
+ function printValue(value, indent) {
2290
+ if (value instanceof Map) {
2291
+ return printRecord(value, indent);
2292
+ }
2293
+ if (Array.isArray(value)) {
2294
+ return printArray(value, indent);
2295
+ }
2296
+ return JSON.stringify(value);
2297
+ }
2298
+ function serializeOrderedJson(value) {
2299
+ return `${printValue(value, "")}
1985
2300
  `;
1986
2301
  }
2302
+ var INVALID_STRUCTURE_MESSAGE = "The file is not a valid object (expected nested objects of string, number, boolean, or null leaves).";
2303
+ function isJsonLeaf(value) {
2304
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null;
2305
+ }
2306
+ function assertNodesValid(root) {
2307
+ const stack = [root];
2308
+ while (stack.length > 0) {
2309
+ const node = stack.pop();
2310
+ if (node === void 0) {
2311
+ break;
2312
+ }
2313
+ for (const [key, child] of node) {
2314
+ if (typeof key !== "string") {
2315
+ throw new AdapterError("INVALID_STRUCTURE", INVALID_STRUCTURE_MESSAGE);
2316
+ }
2317
+ if (child instanceof Map) {
2318
+ stack.push(child);
2319
+ } else if (!isJsonLeaf(child)) {
2320
+ throw new AdapterError("INVALID_STRUCTURE", INVALID_STRUCTURE_MESSAGE);
2321
+ }
2322
+ }
2323
+ }
2324
+ }
2325
+ function assertJsonRecord(value) {
2326
+ assertWithinDepth(value, MAX_DEPTH);
2327
+ if (!(value instanceof Map)) {
2328
+ throw new AdapterError("INVALID_STRUCTURE", INVALID_STRUCTURE_MESSAGE);
2329
+ }
2330
+ assertNodesValid(value);
2331
+ return value;
2332
+ }
2333
+ function parseJsonObject(content) {
2334
+ return assertJsonRecord(parseOrderedJson(content));
2335
+ }
2336
+ function serializeJsonTree(tree) {
2337
+ return serializeOrderedJson(tree);
2338
+ }
2339
+ function isJsonNode(value) {
2340
+ return value instanceof Map;
2341
+ }
1987
2342
  function namespaceOf(filePath) {
1988
2343
  return path.basename(filePath, path.extname(filePath));
1989
2344
  }
@@ -2011,9 +2366,25 @@ function buildCanHandle(extensions, sniff) {
2011
2366
  return sample === void 0 || sniff === void 0 || sniff(sample);
2012
2367
  };
2013
2368
  }
2369
+ async function fsyncPath(path) {
2370
+ const handle = await promises.open(path, "r");
2371
+ try {
2372
+ await handle.sync();
2373
+ } finally {
2374
+ await handle.close();
2375
+ }
2376
+ }
2377
+ async function fsyncDirBestEffort(path) {
2378
+ try {
2379
+ await fsyncPath(path);
2380
+ } catch {
2381
+ }
2382
+ }
2014
2383
  var nodeOps = {
2015
2384
  writeFile: (path, data) => promises.writeFile(path, data, "utf8"),
2385
+ fsyncFile: (path) => fsyncPath(path),
2016
2386
  rename: (from, to) => promises.rename(from, to),
2387
+ fsyncDir: (path) => fsyncDirBestEffort(path),
2017
2388
  rm: (path) => promises.rm(path, { force: true })
2018
2389
  };
2019
2390
  async function cleanup(ops, tmp) {
@@ -2025,15 +2396,20 @@ async function cleanup(ops, tmp) {
2025
2396
  function tempFileName2(path$1) {
2026
2397
  return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
2027
2398
  }
2028
- async function atomicWriteFile(path, data, ops = nodeOps) {
2029
- const tmp = tempFileName2(path);
2399
+ async function atomicWriteFile(path$1, data, ops = nodeOps) {
2400
+ const tmp = tempFileName2(path$1);
2030
2401
  try {
2031
2402
  await ops.writeFile(tmp, data);
2032
- await ops.rename(tmp, path);
2403
+ await ops.fsyncFile(tmp);
2404
+ await ops.rename(tmp, path$1);
2033
2405
  } catch (error) {
2034
2406
  await cleanup(ops, tmp);
2035
2407
  throw error;
2036
2408
  }
2409
+ try {
2410
+ await ops.fsyncDir(path.dirname(path$1));
2411
+ } catch {
2412
+ }
2037
2413
  }
2038
2414
  function stripLeadingBom(content) {
2039
2415
  return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
@@ -2155,21 +2531,25 @@ function addLeaf(ctx, segments, key, value) {
2155
2531
  );
2156
2532
  }
2157
2533
  ctx.claimed.set(effectivePath, mapKey);
2534
+ if (typeof value !== "string") {
2535
+ ctx.excluded.push(mapKey);
2536
+ return;
2537
+ }
2158
2538
  const { placeholders, isPlural } = ctx.derive(key, value);
2159
2539
  ctx.out.set(mapKey, { key: mapKey, namespace: ctx.namespace, value, placeholders, isPlural });
2160
2540
  }
2161
2541
  function addEntries(ctx, prefix, node) {
2162
- for (const [key, value] of Object.entries(node)) {
2542
+ for (const [key, value] of node) {
2163
2543
  const segments = [...prefix, key];
2164
- if (typeof value === "string") {
2165
- addLeaf(ctx, segments, key, value);
2166
- } else {
2544
+ if (isJsonNode(value)) {
2167
2545
  addEntries(ctx, segments, value);
2546
+ } else {
2547
+ addLeaf(ctx, segments, key, value);
2168
2548
  }
2169
2549
  }
2170
2550
  }
2171
- function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2172
- for (const [key, value] of Object.entries(node)) {
2551
+ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths, excluded) {
2552
+ for (const [key, value] of node) {
2173
2553
  const path = prefix === "" ? key : `${prefix}.${key}`;
2174
2554
  if (claimedPaths.has(path)) {
2175
2555
  throw new AdapterError(
@@ -2178,34 +2558,35 @@ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2178
2558
  );
2179
2559
  }
2180
2560
  claimedPaths.add(path);
2181
- if (typeof value === "string") {
2561
+ if (isJsonNode(value)) {
2562
+ addPathEntries(value, path, namespace, derive, out, claimedPaths, excluded);
2563
+ } else if (typeof value === "string") {
2182
2564
  const { placeholders, isPlural } = derive(key, value);
2183
2565
  out.set(path, { key: path, namespace, value, placeholders, isPlural });
2184
2566
  } else {
2185
- addPathEntries(value, path, namespace, derive, out, claimedPaths);
2567
+ excluded.push(path);
2186
2568
  }
2187
2569
  }
2188
2570
  }
2189
2571
  function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
2190
2572
  const out = /* @__PURE__ */ new Map();
2191
2573
  if (keyMode === "path-notation") {
2192
- addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set());
2193
- return out;
2574
+ const excluded = [];
2575
+ addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set(), excluded);
2576
+ return { entries: out, excludedLeafPaths: excluded };
2194
2577
  }
2195
- addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
2196
- return out;
2197
- }
2198
- function emptyNode() {
2199
- return /* @__PURE__ */ Object.create(null);
2578
+ const ctx = { namespace, derive, out, claimed: /* @__PURE__ */ new Map(), excluded: [] };
2579
+ addEntries(ctx, [], tree);
2580
+ return { entries: out, excludedLeafPaths: ctx.excluded };
2200
2581
  }
2201
2582
  function descend(node, segment) {
2202
- const next = node[segment];
2583
+ const next = node.get(segment);
2203
2584
  if (next === void 0) {
2204
- const created = emptyNode();
2205
- node[segment] = created;
2585
+ const created = /* @__PURE__ */ new Map();
2586
+ node.set(segment, created);
2206
2587
  return created;
2207
2588
  }
2208
- if (typeof next === "object") {
2589
+ if (next instanceof Map) {
2209
2590
  return next;
2210
2591
  }
2211
2592
  throw new AdapterError("INVALID_STRUCTURE", "A leaf key collides with a nested key path.");
@@ -2219,13 +2600,13 @@ function setPath(root, segments, value) {
2219
2600
  for (const segment of segments.slice(0, -1)) {
2220
2601
  node = descend(node, segment);
2221
2602
  }
2222
- if (typeof node[leaf] === "object") {
2603
+ if (node.get(leaf) instanceof Map) {
2223
2604
  throw new AdapterError("INVALID_STRUCTURE", "A leaf key collides with a nested key path.");
2224
2605
  }
2225
- node[leaf] = value;
2606
+ node.set(leaf, value);
2226
2607
  }
2227
2608
  function unflattenEntries(entries) {
2228
- const root = emptyNode();
2609
+ const root = /* @__PURE__ */ new Map();
2229
2610
  for (const [key, entry] of entries) {
2230
2611
  setPath(root, decodeKeyToSegments(key), entry.value);
2231
2612
  }
@@ -2246,9 +2627,9 @@ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTre
2246
2627
  try {
2247
2628
  const tree = parse3(content);
2248
2629
  validateTree?.(tree);
2249
- const entries = flattenTree(tree, namespace, deriveEntry, keyMode);
2250
- mergeDescriptions(entries, content, deriveDescriptions);
2251
- return entries;
2630
+ const result = flattenTree(tree, namespace, deriveEntry, keyMode);
2631
+ mergeDescriptions(result.entries, content, deriveDescriptions);
2632
+ return result;
2252
2633
  } catch (error) {
2253
2634
  rethrowStructured(error, "The file could not be parsed.");
2254
2635
  }
@@ -2279,7 +2660,7 @@ function createTreeFileAdapter(options) {
2279
2660
  async read(filePath, locale) {
2280
2661
  const content = await readFileContent(filePath);
2281
2662
  const namespace = namespaceOf(filePath);
2282
- const entries = toEntries(
2663
+ const { entries, excludedLeafPaths } = toEntries(
2283
2664
  content,
2284
2665
  namespace,
2285
2666
  parse3,
@@ -2290,7 +2671,7 @@ function createTreeFileAdapter(options) {
2290
2671
  );
2291
2672
  const resource = { locale, namespace, format, entries };
2292
2673
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2293
- return { resource, invalidIcuKeys };
2674
+ return { resource, invalidIcuKeys, excludedLeafPaths };
2294
2675
  },
2295
2676
  async write(resource, filePath) {
2296
2677
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
@@ -2302,13 +2683,8 @@ function isMetadataKey(key) {
2302
2683
  return key.startsWith("@");
2303
2684
  }
2304
2685
  function parseArbObject(content) {
2305
- let parsed;
2306
- try {
2307
- parsed = JSON.parse(content);
2308
- } catch {
2309
- throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
2310
- }
2311
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2686
+ const parsed = parseOrderedJson(content);
2687
+ if (!(parsed instanceof Map)) {
2312
2688
  throw new AdapterError(
2313
2689
  "INVALID_STRUCTURE",
2314
2690
  "The file is not a valid object (expected nested objects of string values)."
@@ -2317,10 +2693,10 @@ function parseArbObject(content) {
2317
2693
  return parsed;
2318
2694
  }
2319
2695
  function stripArbMetadata(tree) {
2320
- const out = /* @__PURE__ */ Object.create(null);
2321
- for (const [key, value] of Object.entries(tree)) {
2696
+ const out = /* @__PURE__ */ new Map();
2697
+ for (const [key, value] of tree) {
2322
2698
  if (!isMetadataKey(key)) {
2323
- out[key] = value;
2699
+ out.set(key, value);
2324
2700
  }
2325
2701
  }
2326
2702
  return out;
@@ -2332,16 +2708,16 @@ function messageKeyForMetadata(key) {
2332
2708
  return isMetadataKey(key) && !key.startsWith("@@") ? key.slice(1) : null;
2333
2709
  }
2334
2710
  function descriptionOf(value) {
2335
- if (typeof value !== "object" || value === null) {
2711
+ if (!(value instanceof Map)) {
2336
2712
  return void 0;
2337
2713
  }
2338
- const description = value.description;
2714
+ const description = value.get("description");
2339
2715
  return typeof description === "string" ? description : void 0;
2340
2716
  }
2341
2717
  function extractArbDescriptions(content) {
2342
2718
  const tree = parseArbObject(content);
2343
2719
  const out = /* @__PURE__ */ new Map();
2344
- for (const [key, value] of Object.entries(tree)) {
2720
+ for (const [key, value] of tree) {
2345
2721
  const messageKey = messageKeyForMetadata(key);
2346
2722
  if (messageKey === null) {
2347
2723
  continue;
@@ -2373,23 +2749,25 @@ async function readDestinationPairs(filePath) {
2373
2749
  }
2374
2750
  throw error;
2375
2751
  }
2376
- return Object.entries(parseArbObject(content));
2752
+ return [...parseArbObject(content)];
2377
2753
  }
2378
2754
  async function buildArbWriteTree(entries, filePath) {
2379
2755
  const messages = messagesFromEntries(entries);
2380
2756
  const pairs = await readDestinationPairs(filePath);
2381
- const out = /* @__PURE__ */ Object.create(null);
2757
+ const out = /* @__PURE__ */ new Map();
2382
2758
  const consumed = /* @__PURE__ */ new Set();
2383
2759
  for (const [key, value] of pairs ?? []) {
2384
2760
  const translated = isMetadataKey(key) ? void 0 : messages.get(key);
2385
2761
  if (translated !== void 0) {
2386
2762
  consumed.add(key);
2763
+ out.set(key, translated);
2764
+ } else if (isMetadataKey(key) || typeof value === "string") {
2765
+ out.set(key, value);
2387
2766
  }
2388
- out[key] = translated ?? value;
2389
2767
  }
2390
2768
  for (const [key, value] of messages) {
2391
2769
  if (!consumed.has(key)) {
2392
- out[key] = value;
2770
+ out.set(key, value);
2393
2771
  }
2394
2772
  }
2395
2773
  return out;
@@ -2478,8 +2856,8 @@ function createNextIntlJsonAdapter() {
2478
2856
  });
2479
2857
  }
2480
2858
  function assertNoDottedNestedKey(tree) {
2481
- for (const [key, value] of Object.entries(tree)) {
2482
- if (typeof value !== "object") {
2859
+ for (const [key, value] of tree) {
2860
+ if (!isJsonNode(value)) {
2483
2861
  continue;
2484
2862
  }
2485
2863
  if (key.includes(".")) {
@@ -2494,8 +2872,8 @@ function assertNoDottedNestedKey(tree) {
2494
2872
  function assertNotMixed(tree) {
2495
2873
  let hasNested = false;
2496
2874
  let hasFlatDottedKey = false;
2497
- for (const [key, value] of Object.entries(tree)) {
2498
- if (typeof value === "object") {
2875
+ for (const [key, value] of tree) {
2876
+ if (isJsonNode(value)) {
2499
2877
  hasNested = true;
2500
2878
  } else if (key.includes(".")) {
2501
2879
  hasFlatDottedKey = true;
@@ -2531,9 +2909,9 @@ async function detectStyle(filePath) {
2531
2909
  return "flat";
2532
2910
  }
2533
2911
  function buildFlatTree(entries) {
2534
- const out = /* @__PURE__ */ Object.create(null);
2912
+ const out = /* @__PURE__ */ new Map();
2535
2913
  for (const [key, entry] of entries) {
2536
- out[key] = entry.value;
2914
+ out.set(key, entry.value);
2537
2915
  }
2538
2916
  return out;
2539
2917
  }
@@ -2551,7 +2929,6 @@ function createNgxTranslateJsonAdapter() {
2551
2929
  }),
2552
2930
  validateTree: assertNotMixed,
2553
2931
  buildWriteTree: buildNgxWriteTree,
2554
- // ngx-translate flat style uses dotted keys as path notation, not literal leaves.
2555
2932
  keyMode: "path-notation"
2556
2933
  });
2557
2934
  }
@@ -2577,6 +2954,11 @@ var AdapterRegistry = class {
2577
2954
  }
2578
2955
  return { status: "resolved", adapter };
2579
2956
  }
2957
+ /**
2958
+ * Resolve by asking every registered adapter's `canHandle`. Several adapters can claim one file
2959
+ * (all JSON adapters claim `.json`), so more than one match is reported as `ambiguous` rather
2960
+ * than guessed at.
2961
+ */
2580
2962
  resolveByDetection(filePath, sample) {
2581
2963
  const matches = this.adapters.filter((adapter) => adapter.canHandle(filePath, sample));
2582
2964
  const first = matches[0];
@@ -2656,7 +3038,7 @@ function createFlatFileAdapter(options) {
2656
3038
  const entries = toEntries2(content, namespace, parseEntries);
2657
3039
  const resource = { locale, namespace, format, entries };
2658
3040
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2659
- return { resource, invalidIcuKeys };
3041
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
2660
3042
  },
2661
3043
  async write(resource, filePath) {
2662
3044
  const data = await serializeEntries(resource.entries, filePath);
@@ -2931,14 +3313,34 @@ function createXliffAdapter() {
2931
3313
  extractPlaceholders: extractXliffPlaceholders
2932
3314
  });
2933
3315
  }
3316
+ function normalizeKey(key) {
3317
+ if (typeof key === "object" && key !== null) {
3318
+ throw new AdapterError(
3319
+ "INVALID_STRUCTURE",
3320
+ "A mapping key is a map or sequence (expected scalar keys)."
3321
+ );
3322
+ }
3323
+ return String(key);
3324
+ }
3325
+ function normalizeYamlTree(value) {
3326
+ if (!(value instanceof Map)) {
3327
+ return value;
3328
+ }
3329
+ const out = /* @__PURE__ */ new Map();
3330
+ for (const [key, child] of value) {
3331
+ out.set(normalizeKey(key), normalizeYamlTree(child));
3332
+ }
3333
+ return out;
3334
+ }
2934
3335
  function parseYamlObject(content) {
2935
3336
  let parsed;
2936
3337
  try {
2937
- parsed = yaml.parse(content, { maxAliasCount: 100 });
3338
+ parsed = yaml.parse(content, { mapAsMap: true, maxAliasCount: 100 });
2938
3339
  } catch {
2939
3340
  throw new AdapterError("INVALID_YAML", "The file is not valid YAML.");
2940
3341
  }
2941
- return assertJsonRecord(parsed);
3342
+ assertWithinDepth(parsed, MAX_DEPTH);
3343
+ return assertJsonRecord(normalizeYamlTree(parsed));
2942
3344
  }
2943
3345
  function createYamlAdapter() {
2944
3346
  return createTreeFileAdapter({
@@ -2969,6 +3371,15 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
2969
3371
  );
2970
3372
  }
2971
3373
 
3374
+ // src/flow/read-target.ts
3375
+ async function readTargetResource(input) {
3376
+ const path = localeFilePath(input.cwd, input.filesPattern, input.locale);
3377
+ if (!await input.fs.fileExists(path)) {
3378
+ return { locale: input.locale, namespace: "", format: input.format, entries: /* @__PURE__ */ new Map() };
3379
+ }
3380
+ return (await input.adapter.read(path, input.locale)).resource;
3381
+ }
3382
+
2972
3383
  // src/flow/select-locales.ts
2973
3384
  function selectLocales(config, requested) {
2974
3385
  if (requested === void 0) {
@@ -3009,11 +3420,14 @@ async function readSource(config, cwd, fs, adapter) {
3009
3420
 
3010
3421
  // src/flow/diff-locales.ts
3011
3422
  async function readTarget(cwd, config, adapter, fs, locale) {
3012
- const path = localeFilePath(cwd, config.files.pattern, locale);
3013
- if (!await fs.fileExists(path)) {
3014
- return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
3015
- }
3016
- return (await adapter.read(path, locale)).resource;
3423
+ return readTargetResource({
3424
+ cwd,
3425
+ filesPattern: config.files.pattern,
3426
+ format: config.format,
3427
+ locale,
3428
+ adapter,
3429
+ fs
3430
+ });
3017
3431
  }
3018
3432
  async function diffLocales(input, deps = {}) {
3019
3433
  const config = input.config;
@@ -3063,6 +3477,166 @@ async function diff(input, deps = {}) {
3063
3477
  return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
3064
3478
  }
3065
3479
 
3480
+ // src/flow/integrity-gate.ts
3481
+ function gateCandidateValue(sourceEntry, candidateValue, adapter) {
3482
+ const placeholderResult = adapter.comparePlaceholders?.(sourceEntry.value, candidateValue) ?? checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(candidateValue));
3483
+ if (!placeholderResult.matches) {
3484
+ return { accepted: false, reason: "placeholder" };
3485
+ }
3486
+ if (!adapter.validateMessage(candidateValue)) {
3487
+ return { accepted: false, reason: "icu" };
3488
+ }
3489
+ return { accepted: true };
3490
+ }
3491
+
3492
+ // src/flow/edit-entry.ts
3493
+ async function editEntry(input, deps = {}) {
3494
+ const config = input.config;
3495
+ const cwd = input.cwd ?? process.cwd();
3496
+ const fs = deps.fs ?? defaultFs;
3497
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
3498
+ const [locale] = selectLocales(config, [input.locale]);
3499
+ if (locale === void 0) {
3500
+ throw new SdkError("UNKNOWN_LOCALE", `Locale "${input.locale}" could not be resolved.`);
3501
+ }
3502
+ const source = await readSource(config, cwd, fs, adapter);
3503
+ const sourceEntry = source.resource.entries.get(input.key);
3504
+ if (sourceEntry === void 0) {
3505
+ throw new SdkError(
3506
+ "UNKNOWN_KEY",
3507
+ `The key "${input.key}" was not found in the source resource.`
3508
+ );
3509
+ }
3510
+ return withLocaleWriteLock(cwd, locale, fs, async () => {
3511
+ const target = await readTarget(cwd, config, adapter, fs, locale);
3512
+ const gate = gateCandidateValue(sourceEntry, input.value, adapter);
3513
+ if (!gate.accepted) {
3514
+ return { accepted: false, reason: gate.reason, value: input.value };
3515
+ }
3516
+ const merged = new Map(target.entries);
3517
+ merged.set(input.key, { ...sourceEntry, value: input.value, namespace: target.namespace });
3518
+ const path = localeFilePath(cwd, config.files.pattern, locale);
3519
+ await adapter.write(
3520
+ { locale, namespace: target.namespace, format: config.format, entries: merged },
3521
+ path
3522
+ );
3523
+ await updateLockFileLocale(cwd, fs, locale, {
3524
+ mode: "merge",
3525
+ entries: { [input.key]: contentHash(sourceEntry) }
3526
+ });
3527
+ return { accepted: true, value: input.value };
3528
+ });
3529
+ }
3530
+
3531
+ // src/flow/key-integrity.ts
3532
+ function checkEntryIntegrity(adapter, sourceEntry, targetEntry) {
3533
+ const result = adapter.comparePlaceholders?.(sourceEntry.value, targetEntry.value) ?? checkPlaceholders(sourceEntry.placeholders, targetEntry.placeholders);
3534
+ return {
3535
+ key: sourceEntry.key,
3536
+ hasPlaceholders: sourceEntry.placeholders.length > 0,
3537
+ matches: result.matches,
3538
+ missing: result.missing,
3539
+ extra: result.extra,
3540
+ icuValid: adapter.validateMessage(targetEntry.value)
3541
+ };
3542
+ }
3543
+ function selectChangedKeys(changed, requested) {
3544
+ if (requested === void 0) {
3545
+ return changed;
3546
+ }
3547
+ const wanted = new Set(requested);
3548
+ return changed.filter((key) => wanted.has(key));
3549
+ }
3550
+ function integrityEntriesFor(source, target, adapter, changedKeys) {
3551
+ const entries = [];
3552
+ for (const key of changedKeys) {
3553
+ const sourceEntry = source.entries.get(key);
3554
+ const targetEntry = target.entries.get(key);
3555
+ if (sourceEntry === void 0 || targetEntry === void 0) {
3556
+ continue;
3557
+ }
3558
+ entries.push(checkEntryIntegrity(adapter, sourceEntry, targetEntry));
3559
+ }
3560
+ return entries;
3561
+ }
3562
+ async function keyIntegrity(input, deps = {}) {
3563
+ const config = input.config;
3564
+ const cwd = input.cwd ?? process.cwd();
3565
+ const fs = deps.fs ?? defaultFs;
3566
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
3567
+ const source = await readSource(config, cwd, fs, adapter);
3568
+ const lock = await readLockFile(lockFilePath(cwd), fs);
3569
+ return Promise.all(
3570
+ selectLocales(config, input.locales).map(async (locale) => {
3571
+ const target = await readTarget(cwd, config, adapter, fs, locale);
3572
+ const diffResult = diffResources(source.resource, target, {
3573
+ baseline: baselineFor(lock, locale)
3574
+ });
3575
+ const changedKeys = selectChangedKeys(diffResult.changed, input.keys);
3576
+ const entries = integrityEntriesFor(source.resource, target, adapter, changedKeys);
3577
+ return { locale, entries };
3578
+ })
3579
+ );
3580
+ }
3581
+
3582
+ // src/flow/key-value.ts
3583
+ async function keyValue(input, deps = {}) {
3584
+ const config = input.config;
3585
+ const cwd = input.cwd ?? process.cwd();
3586
+ const fs = deps.fs ?? defaultFs;
3587
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
3588
+ const [locale] = selectLocales(config, [input.locale]);
3589
+ if (locale === void 0) {
3590
+ throw new SdkError("UNKNOWN_LOCALE", `Locale "${input.locale}" could not be resolved.`);
3591
+ }
3592
+ const source = await readSource(config, cwd, fs, adapter);
3593
+ const sourceEntry = source.resource.entries.get(input.key);
3594
+ if (sourceEntry === void 0) {
3595
+ throw new SdkError(
3596
+ "UNKNOWN_KEY",
3597
+ `The key "${input.key}" was not found in the source resource.`
3598
+ );
3599
+ }
3600
+ const target = await readTarget(cwd, config, adapter, fs, locale);
3601
+ const targetEntry = target.entries.get(input.key);
3602
+ return {
3603
+ source: sourceEntry.value,
3604
+ ...targetEntry !== void 0 ? { target: targetEntry.value } : {}
3605
+ };
3606
+ }
3607
+
3608
+ // src/flow/locale-snapshot.ts
3609
+ async function readLocaleFileSnapshot(input, deps = {}) {
3610
+ const cwd = input.cwd ?? process.cwd();
3611
+ const fs = deps.fs ?? defaultFs;
3612
+ const adapter = selectAdapter(input.config.format, deps.adapterRegistry);
3613
+ const resource = await readTarget(cwd, input.config, adapter, fs, input.locale);
3614
+ const hashes = /* @__PURE__ */ new Map();
3615
+ for (const [key, entry] of resource.entries) {
3616
+ hashes.set(key, contentHash(entry));
3617
+ }
3618
+ return { locale: input.locale, hashes };
3619
+ }
3620
+ function diffLocaleSnapshots(previous, current) {
3621
+ let added = 0;
3622
+ let changed = 0;
3623
+ for (const [key, hash] of current.hashes) {
3624
+ const previousHash = previous.hashes.get(key);
3625
+ if (previousHash === void 0) {
3626
+ added += 1;
3627
+ } else if (previousHash !== hash) {
3628
+ changed += 1;
3629
+ }
3630
+ }
3631
+ let removed = 0;
3632
+ for (const key of previous.hashes.keys()) {
3633
+ if (!current.hashes.has(key)) {
3634
+ removed += 1;
3635
+ }
3636
+ }
3637
+ return { added, changed, removed };
3638
+ }
3639
+
3066
3640
  // src/flow/lock-state.ts
3067
3641
  function toLockLocaleState(locale, keyCount, diff2) {
3068
3642
  return {
@@ -3110,6 +3684,214 @@ function selectProvider(config, createProvider = buildProvider) {
3110
3684
  }
3111
3685
  }
3112
3686
 
3687
+ // src/flow/retranslate-entry.ts
3688
+ function buildSingleEntryRequest(config, locale, sourceEntry, adapter) {
3689
+ return {
3690
+ sourceLocale: config.sourceLocale,
3691
+ targetLocale: locale,
3692
+ entries: [sourceEntry],
3693
+ extractPlaceholders: adapter.extractPlaceholders,
3694
+ ...config.glossary !== void 0 ? { glossary: config.glossary } : {},
3695
+ ...config.tone !== void 0 ? { tone: config.tone } : {},
3696
+ ...adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: adapter.comparePlaceholders } : {}
3697
+ };
3698
+ }
3699
+ async function retranslateEntry(input, deps = {}) {
3700
+ const config = input.config;
3701
+ const cwd = input.cwd ?? process.cwd();
3702
+ const fs = deps.fs ?? defaultFs;
3703
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
3704
+ const [locale] = selectLocales(config, [input.locale]);
3705
+ if (locale === void 0) {
3706
+ throw new SdkError("UNKNOWN_LOCALE", `Locale "${input.locale}" could not be resolved.`);
3707
+ }
3708
+ const source = await readSource(config, cwd, fs, adapter);
3709
+ const sourceEntry = source.resource.entries.get(input.key);
3710
+ if (sourceEntry === void 0) {
3711
+ throw new SdkError(
3712
+ "UNKNOWN_KEY",
3713
+ `The key "${input.key}" was not found in the source resource.`
3714
+ );
3715
+ }
3716
+ const provider = selectProvider(config.provider, deps.createProvider);
3717
+ return withLocaleWriteLock(cwd, locale, fs, async () => {
3718
+ const target = await readTarget(cwd, config, adapter, fs, locale);
3719
+ const result = await provider.translateBatch(
3720
+ buildSingleEntryRequest(config, locale, sourceEntry, adapter)
3721
+ );
3722
+ const value = result.values.get(input.key);
3723
+ if (value === void 0) {
3724
+ throw new ProviderError(
3725
+ "INVALID_RESPONSE",
3726
+ `The provider returned no translated value for key "${input.key}".`
3727
+ );
3728
+ }
3729
+ const gate = gateCandidateValue(sourceEntry, value, adapter);
3730
+ if (!gate.accepted) {
3731
+ return { accepted: false, reason: gate.reason, value };
3732
+ }
3733
+ const merged = new Map(target.entries);
3734
+ merged.set(input.key, { ...sourceEntry, value, namespace: target.namespace });
3735
+ const path = localeFilePath(cwd, config.files.pattern, locale);
3736
+ await adapter.write(
3737
+ { locale, namespace: target.namespace, format: config.format, entries: merged },
3738
+ path
3739
+ );
3740
+ await updateLockFileLocale(cwd, fs, locale, {
3741
+ mode: "merge",
3742
+ entries: { [input.key]: contentHash(sourceEntry) }
3743
+ });
3744
+ const reviewReasons = result.reviewFlags?.get(input.key)?.reasons ?? [];
3745
+ return { accepted: true, value, reviewReasons };
3746
+ });
3747
+ }
3748
+ var RUN_STATUS_DIR_NAME = ".verbatra-local";
3749
+ var RUN_STATUS_FILE_NAME = "run-status.json";
3750
+ var CURRENT_VERSION2 = 1;
3751
+ var MAX_RUN_STATUS_FILE_BYTES = 16 * 1024 * 1024;
3752
+ var reviewReasonCodeSchema = zod.z.enum([
3753
+ "LENGTH_RATIO_OUTLIER",
3754
+ "EQUALS_SOURCE",
3755
+ "GLOSSARY_TERM_MISSED",
3756
+ "INTEGRITY_REORDERED",
3757
+ "PROVIDER_DEGRADED"
3758
+ ]);
3759
+ var needsReviewEntrySchema = zod.z.object({
3760
+ key: zod.z.string(),
3761
+ reasons: zod.z.array(reviewReasonCodeSchema)
3762
+ });
3763
+ var usageSummarySchema = zod.z.object({
3764
+ inputTokens: zod.z.number().int().nonnegative(),
3765
+ outputTokens: zod.z.number().int().nonnegative()
3766
+ });
3767
+ var runBudgetSchema = zod.z.object({
3768
+ maxTokens: zod.z.number().int().nonnegative(),
3769
+ behavior: zod.z.enum(["warn", "stop"]),
3770
+ supported: zod.z.boolean(),
3771
+ tokensUsed: zod.z.number().int().nonnegative(),
3772
+ exceeded: zod.z.boolean()
3773
+ });
3774
+ var runStatusLocaleSchema = zod.z.object({
3775
+ locale: zod.z.string(),
3776
+ status: zod.z.enum(["succeeded", "failed"]),
3777
+ needsReview: zod.z.array(needsReviewEntrySchema),
3778
+ usage: usageSummarySchema.optional()
3779
+ });
3780
+ var runStatusFileSchema = zod.z.object({
3781
+ version: zod.z.number().int().positive(),
3782
+ generatedAt: zod.z.string(),
3783
+ usage: usageSummarySchema.optional(),
3784
+ budget: runBudgetSchema.optional(),
3785
+ locales: zod.z.array(runStatusLocaleSchema)
3786
+ });
3787
+ function runStatusFilePath(cwd) {
3788
+ return path.resolve(cwd, RUN_STATUS_DIR_NAME, RUN_STATUS_FILE_NAME);
3789
+ }
3790
+ function toRunStatusLocale(locale) {
3791
+ return {
3792
+ locale: locale.locale,
3793
+ status: locale.status,
3794
+ needsReview: locale.needsReview,
3795
+ ...locale.usage !== void 0 ? { usage: locale.usage } : {}
3796
+ };
3797
+ }
3798
+ function buildRunStatusFile(summary, generatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
3799
+ return {
3800
+ version: CURRENT_VERSION2,
3801
+ generatedAt,
3802
+ ...summary.usage !== void 0 ? { usage: summary.usage } : {},
3803
+ ...summary.budget !== void 0 ? { budget: summary.budget } : {},
3804
+ locales: summary.locales.map(toRunStatusLocale)
3805
+ };
3806
+ }
3807
+ function fromParsed(data) {
3808
+ return {
3809
+ version: data.version,
3810
+ generatedAt: data.generatedAt,
3811
+ ...data.usage !== void 0 ? { usage: data.usage } : {},
3812
+ ...data.budget !== void 0 ? { budget: data.budget } : {},
3813
+ locales: data.locales.map((locale) => ({
3814
+ locale: locale.locale,
3815
+ status: locale.status,
3816
+ needsReview: locale.needsReview,
3817
+ ...locale.usage !== void 0 ? { usage: locale.usage } : {}
3818
+ }))
3819
+ };
3820
+ }
3821
+ async function readRunStatusFile(path, fs) {
3822
+ const read = await fs.readFileBounded(path, MAX_RUN_STATUS_FILE_BYTES);
3823
+ if (read.kind !== "ok") {
3824
+ return void 0;
3825
+ }
3826
+ let parsed;
3827
+ try {
3828
+ parsed = JSON.parse(read.content);
3829
+ } catch {
3830
+ return void 0;
3831
+ }
3832
+ const result = runStatusFileSchema.safeParse(parsed);
3833
+ if (!result.success || result.data.version !== CURRENT_VERSION2) {
3834
+ return void 0;
3835
+ }
3836
+ return fromParsed(result.data);
3837
+ }
3838
+ async function writeRunStatusFile(path$1, data, fs) {
3839
+ await promises.mkdir(path.dirname(path$1), { recursive: true });
3840
+ await fs.writeFile(path$1, `${JSON.stringify(data, null, 2)}
3841
+ `);
3842
+ }
3843
+
3844
+ // src/flow/run-status.ts
3845
+ async function runStatus(input = {}, deps = {}) {
3846
+ const cwd = input.cwd ?? process.cwd();
3847
+ const fs = deps.fs ?? defaultFs;
3848
+ const file = await readRunStatusFile(runStatusFilePath(cwd), fs);
3849
+ if (file === void 0) {
3850
+ return { available: false };
3851
+ }
3852
+ return { available: true, ...file };
3853
+ }
3854
+
3855
+ // src/flow/budget.ts
3856
+ function createBudgetTracker(maxTokens, behavior) {
3857
+ return { maxTokens, behavior, tokensUsed: 0, usageSeen: false, exceeded: false, stopped: false };
3858
+ }
3859
+ function foldTrackerUsage(tracker, usage) {
3860
+ if (usage === void 0) {
3861
+ return;
3862
+ }
3863
+ tracker.usageSeen = true;
3864
+ tracker.tokensUsed += usage.inputTokens + usage.outputTokens;
3865
+ }
3866
+ function checkBudgetTrip(tracker) {
3867
+ if (tracker.maxTokens === void 0 || tracker.exceeded || tracker.tokensUsed < tracker.maxTokens) {
3868
+ return false;
3869
+ }
3870
+ tracker.exceeded = true;
3871
+ if (tracker.behavior === "stop") {
3872
+ tracker.stopped = true;
3873
+ }
3874
+ return true;
3875
+ }
3876
+ function toBudgetSummary(tracker) {
3877
+ if (tracker.maxTokens === void 0) {
3878
+ return void 0;
3879
+ }
3880
+ return {
3881
+ maxTokens: tracker.maxTokens,
3882
+ behavior: tracker.behavior,
3883
+ supported: tracker.usageSeen,
3884
+ tokensUsed: tracker.tokensUsed,
3885
+ exceeded: tracker.exceeded
3886
+ };
3887
+ }
3888
+ function budgetExceededNotice(tracker) {
3889
+ return {
3890
+ code: "BUDGET_TOKENS_EXCEEDED",
3891
+ message: `The run's cumulative token usage (${tracker.tokensUsed}) reached the configured budget of ${tracker.maxTokens} tokens (behavior: ${tracker.behavior}).`
3892
+ };
3893
+ }
3894
+
3113
3895
  // src/flow/locale-failure.ts
3114
3896
  function describeError(error) {
3115
3897
  if (error instanceof Error) {
@@ -3129,8 +3911,10 @@ function failureSummary(locale, error) {
3129
3911
  invalidIcuSource: [],
3130
3912
  integrityMismatches: [],
3131
3913
  providerFailures: [],
3914
+ budgetWithheld: [],
3132
3915
  generated: [],
3133
3916
  notices: [],
3917
+ needsReview: [],
3134
3918
  error: describeError(error)
3135
3919
  };
3136
3920
  }
@@ -3300,7 +4084,43 @@ function planPluralGeneration(source, targetLocale, format) {
3300
4084
  return { items };
3301
4085
  }
3302
4086
 
4087
+ // src/flow/usage.ts
4088
+ function createUsageAccumulator() {
4089
+ return { total: void 0 };
4090
+ }
4091
+ function foldUsage(accumulator, usage) {
4092
+ if (usage === void 0) {
4093
+ return;
4094
+ }
4095
+ const prior = accumulator.total ?? { inputTokens: 0, outputTokens: 0 };
4096
+ accumulator.total = {
4097
+ inputTokens: prior.inputTokens + usage.inputTokens,
4098
+ outputTokens: prior.outputTokens + usage.outputTokens
4099
+ };
4100
+ }
4101
+ function combineUsage(a, b) {
4102
+ if (a === void 0) {
4103
+ return b;
4104
+ }
4105
+ if (b === void 0) {
4106
+ return a;
4107
+ }
4108
+ return {
4109
+ inputTokens: a.inputTokens + b.inputTokens,
4110
+ outputTokens: a.outputTokens + b.outputTokens
4111
+ };
4112
+ }
4113
+
3303
4114
  // src/flow/plural-generation.ts
4115
+ var EMPTY_RESULT = {
4116
+ accepted: [],
4117
+ withheld: [],
4118
+ providerFailures: [],
4119
+ budgetWithheld: [],
4120
+ notices: [],
4121
+ usage: void 0,
4122
+ tripped: false
4123
+ };
3304
4124
  function generatedLockHash(governingEntries, category) {
3305
4125
  const governingHashes = governingEntries.map(contentHash).sort();
3306
4126
  return contentHash({
@@ -3314,7 +4134,6 @@ function syntheticEntry(item) {
3314
4134
  ...item.sourceEntry,
3315
4135
  key: item.targetKey,
3316
4136
  isPlural: true,
3317
- // The CLDR category travels as data context (the meaning field), never the instruction channel.
3318
4137
  meaning: `CLDR plural category "${item.category}"`
3319
4138
  };
3320
4139
  }
@@ -3339,23 +4158,45 @@ async function generatePluralForms(context) {
3339
4158
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
3340
4159
  const stale = staleItems(plan.items, context.baseline);
3341
4160
  if (stale.length === 0) {
3342
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
4161
+ return EMPTY_RESULT;
3343
4162
  }
3344
4163
  const accepted = [];
3345
4164
  const withheld = [];
3346
4165
  const providerFailures = [];
4166
+ const budgetWithheld = [];
3347
4167
  const notices = [];
4168
+ const usage = createUsageAccumulator();
4169
+ let tripped = false;
3348
4170
  for (const batch of chunk(stale, context.maxBatchSize)) {
3349
- const batchNotices = await runGenerationSubBatch(
4171
+ if (context.budget.stopped) {
4172
+ for (const item of batch) {
4173
+ budgetWithheld.push(item.targetKey);
4174
+ }
4175
+ continue;
4176
+ }
4177
+ const subResult = await runGenerationSubBatch(
3350
4178
  context,
3351
4179
  batch,
3352
4180
  accepted,
3353
4181
  withheld,
3354
4182
  providerFailures
3355
4183
  );
3356
- notices.push(...batchNotices);
4184
+ notices.push(...subResult.notices);
4185
+ foldUsage(usage, subResult.usage);
4186
+ foldTrackerUsage(context.budget, subResult.usage);
4187
+ if (checkBudgetTrip(context.budget)) {
4188
+ tripped = true;
4189
+ }
3357
4190
  }
3358
- return { accepted, withheld, providerFailures, notices };
4191
+ return {
4192
+ accepted,
4193
+ withheld,
4194
+ providerFailures,
4195
+ budgetWithheld,
4196
+ notices,
4197
+ usage: usage.total,
4198
+ tripped
4199
+ };
3359
4200
  }
3360
4201
  async function runGenerationSubBatch(context, batch, accepted, withheld, providerFailures) {
3361
4202
  let result;
@@ -3366,20 +4207,20 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3366
4207
  for (const item of batch) {
3367
4208
  providerFailures.push(item.targetKey);
3368
4209
  }
3369
- return [subBatchFailedNotice(batch.length, error)];
4210
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3370
4211
  }
3371
4212
  for (const item of batch) {
3372
- foldGenerationItem(item, result, accepted, withheld, providerFailures);
4213
+ foldGenerationItem(item, result, context.adapter, accepted, withheld, providerFailures);
3373
4214
  }
3374
- return readNotices(result);
4215
+ return { notices: readNotices(result), usage: result.usage };
3375
4216
  }
3376
- function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
4217
+ function foldGenerationItem(item, result, adapter, accepted, withheld, providerFailures) {
3377
4218
  const value = result.values.get(item.targetKey);
3378
4219
  if (value === void 0) {
3379
4220
  providerFailures.push(item.targetKey);
3380
4221
  return;
3381
4222
  }
3382
- if (result.integrity.get(item.targetKey)?.matches === true) {
4223
+ if (gateCandidateValue(item.sourceEntry, value, adapter).accepted) {
3383
4224
  accepted.push({
3384
4225
  targetKey: item.targetKey,
3385
4226
  entry: { ...syntheticEntry(item), value },
@@ -3391,16 +4232,6 @@ function foldGenerationItem(item, result, accepted, withheld, providerFailures)
3391
4232
  }
3392
4233
 
3393
4234
  // src/flow/locale-run.ts
3394
- function emptyResource(locale, format) {
3395
- return { locale, namespace: "", format, entries: /* @__PURE__ */ new Map() };
3396
- }
3397
- async function readTarget2(params) {
3398
- const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
3399
- if (!await params.fs.fileExists(path)) {
3400
- return emptyResource(params.targetLocale, params.format);
3401
- }
3402
- return (await params.adapter.read(path, params.targetLocale)).resource;
3403
- }
3404
4235
  function buildRequest3(params, entries) {
3405
4236
  return {
3406
4237
  sourceLocale: params.sourceLocale,
@@ -3413,7 +4244,14 @@ function buildRequest3(params, entries) {
3413
4244
  };
3414
4245
  }
3415
4246
  async function runLocale(params) {
3416
- const target = await readTarget2(params);
4247
+ const target = await readTargetResource({
4248
+ cwd: params.cwd,
4249
+ filesPattern: params.filesPattern,
4250
+ format: params.format,
4251
+ locale: params.targetLocale,
4252
+ adapter: params.adapter,
4253
+ fs: params.fs
4254
+ });
3417
4255
  const diff2 = diffResources(params.source, target, { baseline: params.baseline });
3418
4256
  const orphaned = params.generatePlurals ? diff2.orphaned.filter((key) => !isGeneratedPluralKey(key, sourcePluralBaseKeys(params.source))) : diff2.orphaned;
3419
4257
  const pruned = params.prune ? orphaned : [];
@@ -3439,6 +4277,7 @@ async function runLocale(params) {
3439
4277
  generated: [],
3440
4278
  integrityMismatches: [],
3441
4279
  providerFailures: [],
4280
+ budgetWithheld: [],
3442
4281
  pruned,
3443
4282
  notices: sdkNotices
3444
4283
  }),
@@ -3446,23 +4285,31 @@ async function runLocale(params) {
3446
4285
  };
3447
4286
  }
3448
4287
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
4288
+ const startedStopped = params.budget.stopped;
3449
4289
  const accepted = /* @__PURE__ */ new Map();
3450
4290
  const integrityMismatches = [];
3451
4291
  const providerFailures = [];
3452
- const subBatchNotices = await translateAndCheck(
4292
+ const budgetWithheld = [];
4293
+ const reviewFlags = /* @__PURE__ */ new Map();
4294
+ const translation = await translateAndCheck(
3453
4295
  provider,
3454
4296
  params,
3455
4297
  entries,
3456
4298
  accepted,
3457
4299
  integrityMismatches,
3458
- providerFailures
4300
+ providerFailures,
4301
+ budgetWithheld,
4302
+ reviewFlags
3459
4303
  );
3460
4304
  const merged = new Map(target.entries);
3461
4305
  for (const key of pruned) {
3462
4306
  merged.delete(key);
3463
4307
  }
3464
- for (const [key, { value, source }] of accepted) {
3465
- merged.set(key, { ...source, value, namespace: target.namespace });
4308
+ for (const key of params.source.entries.keys()) {
4309
+ const hit = accepted.get(key);
4310
+ if (hit !== void 0) {
4311
+ merged.set(key, { ...hit.source, value: hit.value, namespace: target.namespace });
4312
+ }
3466
4313
  }
3467
4314
  const generation = await runGeneration(params, provider);
3468
4315
  for (const form of generation.accepted) {
@@ -3481,16 +4328,19 @@ async function runLocale(params) {
3481
4328
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
3482
4329
  const notices = [
3483
4330
  ...pluralNotices,
3484
- ...subBatchNotices,
3485
- ...generation.notices
4331
+ ...translation.notices,
4332
+ ...generation.notices,
4333
+ ...budgetLocaleNotices(params.budget, startedStopped, translation.tripped, generation.tripped)
3486
4334
  ];
3487
4335
  const withheld = /* @__PURE__ */ new Set([
3488
4336
  ...integrityMismatches,
3489
4337
  ...providerFailures,
3490
4338
  ...invalidIcuSource,
3491
4339
  ...generation.withheld,
3492
- ...generation.providerFailures
4340
+ ...generation.providerFailures,
4341
+ ...budgetWithheld
3493
4342
  ]);
4343
+ const localeUsage = combineUsage(translation.usage, generation.usage);
3494
4344
  return {
3495
4345
  summary: baseSummary({
3496
4346
  locale: params.targetLocale,
@@ -3499,19 +4349,29 @@ async function runLocale(params) {
3499
4349
  invalidIcuSource,
3500
4350
  translated: [...accepted.keys()],
3501
4351
  generated: generation.accepted.map((form) => form.targetKey).sort(),
3502
- // Withheld generated forms surface alongside withheld translations: both failed integrity.
3503
4352
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
3504
- // A generation sub-batch whose provider call itself threw is a provider failure, never integrity.
3505
4353
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
4354
+ budgetWithheld: [...budgetWithheld, ...generation.budgetWithheld].sort(),
3506
4355
  pruned,
3507
- notices
4356
+ notices,
4357
+ needsReview: needsReviewFor(accepted.keys(), reviewFlags),
4358
+ ...localeUsage !== void 0 ? { usage: localeUsage } : {}
3508
4359
  }),
3509
4360
  lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
3510
4361
  };
3511
4362
  }
4363
+ var NO_GENERATION_RESULT = {
4364
+ accepted: [],
4365
+ withheld: [],
4366
+ providerFailures: [],
4367
+ budgetWithheld: [],
4368
+ notices: [],
4369
+ usage: void 0,
4370
+ tripped: false
4371
+ };
3512
4372
  async function runGeneration(params, provider) {
3513
4373
  if (!params.generatePlurals || provider.kind !== "llm") {
3514
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
4374
+ return NO_GENERATION_RESULT;
3515
4375
  }
3516
4376
  return generatePluralForms({
3517
4377
  source: params.source,
@@ -3523,9 +4383,13 @@ async function runGeneration(params, provider) {
3523
4383
  glossary: params.glossary,
3524
4384
  tone: params.tone,
3525
4385
  baseline: params.baseline,
3526
- maxBatchSize: params.maxBatchSize
4386
+ maxBatchSize: params.maxBatchSize,
4387
+ budget: params.budget
3527
4388
  });
3528
4389
  }
4390
+ function budgetLocaleNotices(budget, startedStopped, mainTripped, generationTripped) {
4391
+ return startedStopped || mainTripped || generationTripped ? [budgetExceededNotice(budget)] : [];
4392
+ }
3529
4393
  function pluralNoticeFor(params, merged) {
3530
4394
  if (params.format !== "i18next-json") {
3531
4395
  return [];
@@ -3546,26 +4410,53 @@ function baseSummary(parts) {
3546
4410
  invalidIcuSource: parts.invalidIcuSource,
3547
4411
  integrityMismatches: parts.integrityMismatches,
3548
4412
  providerFailures: parts.providerFailures,
4413
+ budgetWithheld: parts.budgetWithheld,
3549
4414
  generated: parts.generated,
3550
- notices: parts.notices
4415
+ notices: parts.notices,
4416
+ needsReview: parts.needsReview ?? [],
4417
+ ...parts.usage !== void 0 ? { usage: parts.usage } : {}
3551
4418
  };
3552
4419
  }
3553
- async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures) {
4420
+ function needsReviewFor(acceptedKeys, reviewFlags) {
4421
+ const entries = [];
4422
+ for (const key of acceptedKeys) {
4423
+ const flag = reviewFlags.get(key);
4424
+ if (flag !== void 0) {
4425
+ entries.push({ key, reasons: flag.reasons });
4426
+ }
4427
+ }
4428
+ return entries.sort((a, b) => a.key < b.key ? -1 : 1);
4429
+ }
4430
+ async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures, budgetWithheld, reviewFlags) {
3554
4431
  const notices = [];
4432
+ const usage = createUsageAccumulator();
4433
+ let tripped = false;
3555
4434
  for (const batch of chunk(entries, params.maxBatchSize)) {
3556
- const subNotices = await runSubBatch(
4435
+ if (params.budget.stopped) {
4436
+ for (const entry of batch) {
4437
+ budgetWithheld.push(entry.key);
4438
+ }
4439
+ continue;
4440
+ }
4441
+ const subResult = await runSubBatch(
3557
4442
  provider,
3558
4443
  params,
3559
4444
  batch,
3560
4445
  accepted,
3561
4446
  integrityMismatches,
3562
- providerFailures
4447
+ providerFailures,
4448
+ reviewFlags
3563
4449
  );
3564
- notices.push(...subNotices);
4450
+ notices.push(...subResult.notices);
4451
+ foldUsage(usage, subResult.usage);
4452
+ foldTrackerUsage(params.budget, subResult.usage);
4453
+ if (checkBudgetTrip(params.budget)) {
4454
+ tripped = true;
4455
+ }
3565
4456
  }
3566
- return notices;
4457
+ return { notices, tripped, usage: usage.total };
3567
4458
  }
3568
- async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures) {
4459
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
3569
4460
  let result;
3570
4461
  try {
3571
4462
  result = await provider.translateBatch(buildRequest3(params, batch));
@@ -3573,20 +4464,25 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3573
4464
  for (const entry of batch) {
3574
4465
  providerFailures.push(entry.key);
3575
4466
  }
3576
- return [subBatchFailedNotice(batch.length, error)];
4467
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3577
4468
  }
3578
4469
  for (const entry of batch) {
3579
- foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
4470
+ foldEntryResult(entry, result, params.adapter, accepted, integrityMismatches, providerFailures);
4471
+ }
4472
+ if (result.reviewFlags !== void 0) {
4473
+ for (const [key, flag] of result.reviewFlags) {
4474
+ reviewFlags.set(key, flag);
4475
+ }
3580
4476
  }
3581
- return readNotices(result);
4477
+ return { notices: readNotices(result), usage: result.usage };
3582
4478
  }
3583
- function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
4479
+ function foldEntryResult(entry, result, adapter, accepted, integrityMismatches, providerFailures) {
3584
4480
  const value = result.values.get(entry.key);
3585
4481
  if (value === void 0) {
3586
4482
  providerFailures.push(entry.key);
3587
4483
  return;
3588
4484
  }
3589
- if (result.integrity.get(entry.key)?.matches === true) {
4485
+ if (gateCandidateValue(entry, value, adapter).accepted) {
3590
4486
  accepted.set(entry.key, { value, source: entry });
3591
4487
  } else {
3592
4488
  integrityMismatches.push(entry.key);
@@ -3628,6 +4524,79 @@ function carryGeneratedLock(lockEntries, baseline, key, sourceBaseKeys) {
3628
4524
  }
3629
4525
 
3630
4526
  // src/flow/translate-project.ts
4527
+ async function recordRunStatus(cwd, dryRun, summary, fs) {
4528
+ if (dryRun) {
4529
+ return;
4530
+ }
4531
+ try {
4532
+ await writeRunStatusFile(runStatusFilePath(cwd), buildRunStatusFile(summary), fs);
4533
+ } catch {
4534
+ }
4535
+ }
4536
+ function buildLocaleRunParams(context, targetLocale, baseline) {
4537
+ return {
4538
+ source: context.source.resource,
4539
+ sourceInvalidIcuKeys: context.source.invalidIcuKeys,
4540
+ baseline,
4541
+ adapter: context.adapter,
4542
+ provider: context.provider,
4543
+ cwd: context.cwd,
4544
+ filesPattern: context.config.files.pattern,
4545
+ sourceLocale: context.config.sourceLocale,
4546
+ targetLocale,
4547
+ format: context.config.format,
4548
+ glossary: context.config.glossary,
4549
+ tone: context.config.tone,
4550
+ prune: context.prune,
4551
+ generatePlurals: context.generatePlurals,
4552
+ maxBatchSize: context.maxBatchSize,
4553
+ fs: context.fs,
4554
+ budget: context.budget
4555
+ };
4556
+ }
4557
+ async function runDryLocale(context, targetLocale, lock) {
4558
+ const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
4559
+ return (await runLocale(params)).summary;
4560
+ }
4561
+ async function runLiveLocale(context, targetLocale) {
4562
+ return withLocaleWriteLock(context.cwd, targetLocale, context.fs, async () => {
4563
+ const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4564
+ const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
4565
+ const result = await runLocale(params);
4566
+ await updateLockFileLocale(context.cwd, context.fs, targetLocale, {
4567
+ mode: "replace",
4568
+ entries: result.lockEntries
4569
+ });
4570
+ return result.summary;
4571
+ });
4572
+ }
4573
+ async function runOneLocale(targetLocale, run) {
4574
+ try {
4575
+ return await run();
4576
+ } catch (error) {
4577
+ if (error instanceof SdkError && error.code === "LOCK_FILE_INVALID") {
4578
+ throw error;
4579
+ }
4580
+ return failureSummary(targetLocale, error);
4581
+ }
4582
+ }
4583
+ async function runAllLocalesDry(context, targetLocales) {
4584
+ const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4585
+ const summaries = [];
4586
+ for (const targetLocale of targetLocales) {
4587
+ summaries.push(
4588
+ await runOneLocale(targetLocale, () => runDryLocale(context, targetLocale, lock))
4589
+ );
4590
+ }
4591
+ return summaries;
4592
+ }
4593
+ async function runAllLocalesLive(context, targetLocales) {
4594
+ const summaries = [];
4595
+ for (const targetLocale of targetLocales) {
4596
+ summaries.push(await runOneLocale(targetLocale, () => runLiveLocale(context, targetLocale)));
4597
+ }
4598
+ return summaries;
4599
+ }
3631
4600
  async function translate2(input, deps = {}) {
3632
4601
  const config = input.config;
3633
4602
  const cwd = input.cwd ?? process.cwd();
@@ -3636,44 +4605,42 @@ async function translate2(input, deps = {}) {
3636
4605
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
3637
4606
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
3638
4607
  const fs = deps.fs ?? defaultFs;
4608
+ const budget = createBudgetTracker(
4609
+ config.maxTokens,
4610
+ config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
4611
+ );
3639
4612
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
3640
4613
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
3641
4614
  const source = await readSource(config, cwd, fs, adapter);
3642
- const lockPath = lockFilePath(cwd);
3643
- let lock = await readLockFile(lockPath, fs);
3644
- const summaries = [];
3645
- for (const targetLocale of config.targetLocales) {
3646
- try {
3647
- const params = {
3648
- source: source.resource,
3649
- sourceInvalidIcuKeys: source.invalidIcuKeys,
3650
- baseline: baselineFor(lock, targetLocale),
3651
- adapter,
3652
- provider,
3653
- cwd,
3654
- filesPattern: config.files.pattern,
3655
- sourceLocale: config.sourceLocale,
3656
- targetLocale,
3657
- format: config.format,
3658
- glossary: config.glossary,
3659
- tone: config.tone,
3660
- prune,
3661
- generatePlurals,
3662
- maxBatchSize,
3663
- fs
3664
- };
3665
- const { summary, lockEntries } = await runLocale(params);
3666
- if (!dryRun) {
3667
- lock = updateLockLocale(lock, targetLocale, lockEntries);
3668
- await writeLockFile(lockPath, lock, fs);
3669
- }
3670
- summaries.push(summary);
3671
- } catch (error) {
3672
- summaries.push(failureSummary(targetLocale, error));
3673
- }
3674
- }
4615
+ const context = {
4616
+ source,
4617
+ adapter,
4618
+ provider,
4619
+ cwd,
4620
+ config,
4621
+ prune,
4622
+ generatePlurals,
4623
+ maxBatchSize,
4624
+ fs,
4625
+ budget
4626
+ };
4627
+ const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales) : await runAllLocalesLive(context, config.targetLocales);
3675
4628
  const { succeeded, failed } = partition(summaries);
3676
- return { dryRun, locales: summaries, succeeded, failed };
4629
+ const usage = summaries.reduce(
4630
+ (total, summary2) => combineUsage(total, summary2.usage),
4631
+ void 0
4632
+ );
4633
+ const budgetSummary = toBudgetSummary(budget);
4634
+ const summary = {
4635
+ dryRun,
4636
+ locales: summaries,
4637
+ succeeded,
4638
+ failed,
4639
+ ...usage !== void 0 ? { usage } : {},
4640
+ ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
4641
+ };
4642
+ await recordRunStatus(cwd, dryRun, summary, fs);
4643
+ return summary;
3677
4644
  }
3678
4645
  var ExchangeError = class extends Error {
3679
4646
  code;
@@ -3714,7 +4681,9 @@ var COLUMN = {
3714
4681
  status: 4,
3715
4682
  translation: 5,
3716
4683
  sourceHash: 6,
3717
- context: 7
4684
+ context: 7,
4685
+ reviewStatus: 8,
4686
+ reviewReasons: 9
3718
4687
  };
3719
4688
  var HEADERS = [
3720
4689
  "Key",
@@ -3723,7 +4692,9 @@ var HEADERS = [
3723
4692
  "Status",
3724
4693
  "Translation",
3725
4694
  "Source hash",
3726
- "Context"
4695
+ "Context",
4696
+ "Review status",
4697
+ "Review reasons"
3727
4698
  ];
3728
4699
  var HEADER_ROW = 1;
3729
4700
  var INSTRUCTIONS_SHEET_NAME = "Instructions";
@@ -3743,7 +4714,9 @@ var COLUMN_WIDTHS = {
3743
4714
  [COLUMN.current]: 50,
3744
4715
  [COLUMN.status]: 12,
3745
4716
  [COLUMN.translation]: 50,
3746
- [COLUMN.context]: 50
4717
+ [COLUMN.context]: 50,
4718
+ [COLUMN.reviewStatus]: 12,
4719
+ [COLUMN.reviewReasons]: 40
3747
4720
  };
3748
4721
  var TEXT_NUMBER_FORMAT = "@";
3749
4722
  function styleHeader(sheet) {
@@ -3775,7 +4748,9 @@ function writeRow(sheet, sheetRow) {
3775
4748
  translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
3776
4749
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
3777
4750
  row.getCell(COLUMN.context).value = sheetRow.context;
3778
- for (let column = COLUMN.key; column <= COLUMN.context; column += 1) {
4751
+ row.getCell(COLUMN.reviewStatus).value = sheetRow.reviewStatus;
4752
+ row.getCell(COLUMN.reviewReasons).value = sheetRow.reviewReasons;
4753
+ for (let column = COLUMN.key; column <= COLUMN.reviewReasons; column += 1) {
3779
4754
  const cell = row.getCell(column);
3780
4755
  cell.protection = { locked: column !== COLUMN.translation };
3781
4756
  if (column !== COLUMN.translation) {
@@ -3950,7 +4925,9 @@ var rowSchema = zod.z.object({
3950
4925
  status: zod.z.enum(["new", "changed", "unchanged"]),
3951
4926
  sourceHash: zod.z.string(),
3952
4927
  translation: zod.z.string(),
3953
- context: zod.z.string()
4928
+ context: zod.z.string(),
4929
+ reviewStatus: zod.z.enum(["ok", "review"]).catch("ok"),
4930
+ reviewReasons: zod.z.string().catch("")
3954
4931
  });
3955
4932
  function cellString(cell) {
3956
4933
  const value = cell.value;
@@ -3984,9 +4961,9 @@ function parseRow(sheet, row) {
3984
4961
  status: cellString(row.getCell(COLUMN.status)),
3985
4962
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
3986
4963
  translation: cellString(row.getCell(COLUMN.translation)),
3987
- // A workbook built before the Context column existed has no cell here; getCell auto-vivifies an
3988
- // empty one, so cellString yields "" and the row still validates, keeping import backward-compatible.
3989
- context: cellString(row.getCell(COLUMN.context))
4964
+ context: cellString(row.getCell(COLUMN.context)),
4965
+ reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
4966
+ reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
3990
4967
  };
3991
4968
  const result = rowSchema.safeParse(candidate);
3992
4969
  if (!result.success) {
@@ -4053,14 +5030,34 @@ async function readWorkbook(bytes, options = {}) {
4053
5030
 
4054
5031
  // src/flow/workbook/export-workbook.ts
4055
5032
  var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
4056
- async function readTarget3(cwd, config, adapter, fs, locale) {
4057
- const path = localeFilePath(cwd, config.files.pattern, locale);
4058
- if (!await fs.fileExists(path)) {
4059
- return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
5033
+ function reasonLabel(reason) {
5034
+ return reason.toLowerCase().replace(/_/g, "-");
5035
+ }
5036
+ function reviewColumns(flag) {
5037
+ if (flag === void 0) {
5038
+ return { reviewStatus: "ok", reviewReasons: "" };
4060
5039
  }
4061
- return (await adapter.read(path, locale)).resource;
5040
+ return { reviewStatus: "review", reviewReasons: flag.reasons.map(reasonLabel).join(", ") };
4062
5041
  }
4063
- function buildRows(source, target, baseline, includeUnchanged) {
5042
+ function computeRowReview(adapter, sourceValue, currentTarget, sourceLocale, targetLocale, glossary) {
5043
+ if (currentTarget === "") {
5044
+ return { reviewStatus: "ok", reviewReasons: "" };
5045
+ }
5046
+ const integrity = adapter.comparePlaceholders?.(sourceValue, currentTarget) ?? checkPlaceholders(
5047
+ adapter.extractPlaceholders(sourceValue),
5048
+ adapter.extractPlaceholders(currentTarget)
5049
+ );
5050
+ const flag = computeReviewFlags({
5051
+ sourceValue,
5052
+ translatedValue: currentTarget,
5053
+ sourceLocale,
5054
+ targetLocale,
5055
+ integrity,
5056
+ glossary
5057
+ });
5058
+ return reviewColumns(flag);
5059
+ }
5060
+ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary) {
4064
5061
  const diff2 = diffResources(source, target, { baseline });
4065
5062
  const rows = [];
4066
5063
  const add = (keys, status) => {
@@ -4069,14 +5066,23 @@ function buildRows(source, target, baseline, includeUnchanged) {
4069
5066
  if (sourceEntry === void 0) {
4070
5067
  continue;
4071
5068
  }
5069
+ const currentTarget = target.entries.get(key)?.value ?? "";
4072
5070
  rows.push({
4073
5071
  key,
4074
5072
  source: sourceEntry.value,
4075
- currentTarget: target.entries.get(key)?.value ?? "",
5073
+ currentTarget,
4076
5074
  status,
4077
5075
  sourceHash: contentHash(sourceEntry),
4078
5076
  translation: "",
4079
- context: sourceEntry.description ?? ""
5077
+ context: sourceEntry.description ?? "",
5078
+ ...computeRowReview(
5079
+ adapter,
5080
+ sourceEntry.value,
5081
+ currentTarget,
5082
+ source.locale,
5083
+ target.locale,
5084
+ glossary
5085
+ )
4080
5086
  });
4081
5087
  }
4082
5088
  };
@@ -4097,12 +5103,14 @@ async function exportWorkbook(input, deps = {}) {
4097
5103
  const locales = selectLocales(config, input.locales);
4098
5104
  const sheets = await Promise.all(
4099
5105
  locales.map(async (locale) => {
4100
- const target = await readTarget3(cwd, config, adapter, fs, locale);
5106
+ const target = await readTarget(cwd, config, adapter, fs, locale);
4101
5107
  const rows = buildRows(
4102
5108
  source.resource,
4103
5109
  target,
4104
5110
  baselineFor(lock, locale),
4105
- input.includeUnchanged ?? false
5111
+ input.includeUnchanged ?? false,
5112
+ adapter,
5113
+ config.glossary
4106
5114
  );
4107
5115
  return { locale, rows };
4108
5116
  })
@@ -4133,14 +5141,8 @@ function judge(row, sourceEntry, adapter) {
4133
5141
  if (contentHash(sourceEntry) !== row.sourceHash) {
4134
5142
  return "drift";
4135
5143
  }
4136
- const integrity = adapter.comparePlaceholders !== void 0 ? adapter.comparePlaceholders(sourceEntry.value, row.translation) : checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(row.translation));
4137
- if (!integrity.matches) {
4138
- return "placeholder";
4139
- }
4140
- if (!adapter.validateMessage(row.translation)) {
4141
- return "icu";
4142
- }
4143
- return void 0;
5144
+ const gate = gateCandidateValue(sourceEntry, row.translation, adapter);
5145
+ return gate.accepted ? void 0 : gate.reason;
4144
5146
  }
4145
5147
  function trackBlankDrift(row, params, buckets) {
4146
5148
  const sourceEntry = params.source.entries.get(row.key);
@@ -4197,15 +5199,14 @@ function importLocale(params) {
4197
5199
  translated: [...buckets.accepted.keys()].sort(),
4198
5200
  unchanged: diff2.unchanged,
4199
5201
  orphaned: diff2.orphaned,
4200
- // Import never prunes: orphans are reported but never removed here (pruning is a translate-flow concern).
4201
5202
  pruned: [],
4202
5203
  invalidIcuSource,
4203
5204
  integrityMismatches: [...buckets.mismatches].sort(),
4204
- // A workbook import never calls a provider, so a provider-call failure cannot occur here.
4205
5205
  providerFailures: [],
4206
- // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
5206
+ budgetWithheld: [],
4207
5207
  generated: [],
4208
- notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : []
5208
+ notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
5209
+ needsReview: []
4209
5210
  };
4210
5211
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
4211
5212
  }
@@ -4225,13 +5226,6 @@ async function readWorkbookBytes(path, fs) {
4225
5226
  }
4226
5227
  return read.bytes;
4227
5228
  }
4228
- async function readTarget4(cwd, config, adapter, fs, locale) {
4229
- const path = localeFilePath(cwd, config.files.pattern, locale);
4230
- if (!await fs.fileExists(path)) {
4231
- return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
4232
- }
4233
- return (await adapter.read(path, locale)).resource;
4234
- }
4235
5229
  function mergeAccepted(target, accepted) {
4236
5230
  const merged = new Map(target.entries);
4237
5231
  for (const [key, { value, source }] of accepted) {
@@ -4262,7 +5256,7 @@ async function runSheet(ctx, sheet, lock) {
4262
5256
  `The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
4263
5257
  );
4264
5258
  }
4265
- const target = await readTarget4(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
5259
+ const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
4266
5260
  const baseline = baselineFor(lock, sheet.locale);
4267
5261
  const { summary, accepted } = importLocale({
4268
5262
  sheet,
@@ -4305,8 +5299,7 @@ async function importWorkbook(input, deps = {}) {
4305
5299
  } catch (error) {
4306
5300
  throw new SdkError("SOURCE_INVALID", error.message);
4307
5301
  }
4308
- const lockPath = lockFilePath(cwd);
4309
- let lock = await readLockFile(lockPath, fs);
5302
+ const lock = await readLockFile(lockFilePath(cwd), fs);
4310
5303
  const ctx = {
4311
5304
  config,
4312
5305
  cwd,
@@ -4319,10 +5312,18 @@ async function importWorkbook(input, deps = {}) {
4319
5312
  const summaries = [];
4320
5313
  for (const sheet of data.sheets) {
4321
5314
  try {
4322
- const { summary, lockEntries } = await runSheet(ctx, sheet, lock);
4323
- if (!dryRun) {
4324
- lock = updateLockLocale(lock, sheet.locale, lockEntries);
4325
- await writeLockFile(lockPath, lock, fs);
5315
+ let summary;
5316
+ if (dryRun) {
5317
+ summary = (await runSheet(ctx, sheet, lock)).summary;
5318
+ } else {
5319
+ summary = await withLocaleWriteLock(cwd, sheet.locale, fs, async () => {
5320
+ const result = await runSheet(ctx, sheet, lock);
5321
+ await updateLockFileLocale(cwd, fs, sheet.locale, {
5322
+ mode: "replace",
5323
+ entries: result.lockEntries
5324
+ });
5325
+ return result.summary;
5326
+ });
4326
5327
  }
4327
5328
  summaries.push(summary);
4328
5329
  } catch (error) {
@@ -4461,12 +5462,19 @@ exports.SdkError = SdkError;
4461
5462
  exports.check = check;
4462
5463
  exports.defineConfig = defineConfig;
4463
5464
  exports.diff = diff;
5465
+ exports.diffLocaleSnapshots = diffLocaleSnapshots;
5466
+ exports.editEntry = editEntry;
4464
5467
  exports.exportWorkbook = exportWorkbook;
4465
5468
  exports.importWorkbook = importWorkbook;
5469
+ exports.keyIntegrity = keyIntegrity;
5470
+ exports.keyValue = keyValue;
4466
5471
  exports.loadConfig = loadConfig;
4467
5472
  exports.loadConfigWithMeta = loadConfigWithMeta;
4468
5473
  exports.loadLockFile = loadLockFile;
4469
5474
  exports.lockState = lockState;
5475
+ exports.readLocaleFileSnapshot = readLocaleFileSnapshot;
5476
+ exports.retranslateEntry = retranslateEntry;
5477
+ exports.runStatus = runStatus;
4470
5478
  exports.scaffoldingMetadata = scaffoldingMetadata;
4471
5479
  exports.translate = translate2;
4472
5480
  exports.verbatraConfigSchema = verbatraConfigSchema;