@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.js CHANGED
@@ -3,7 +3,7 @@ import { dirname, resolve, join, basename, extname } from 'path';
3
3
  import { cosmiconfig } from 'cosmiconfig';
4
4
  import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
5
5
  import { randomUUID } from 'crypto';
6
- import { access, writeFile, rename, rm, open } from 'fs/promises';
6
+ import { rm, access, mkdir, open, writeFile, rename } from 'fs/promises';
7
7
  import { z } from 'zod';
8
8
  import Anthropic from '@anthropic-ai/sdk';
9
9
  import * as deepl from 'deepl-node';
@@ -115,6 +115,24 @@ async function atomicWrite(path, data) {
115
115
  throw error;
116
116
  }
117
117
  }
118
+ async function createExclusive(path, data) {
119
+ await mkdir(dirname(path), { recursive: true });
120
+ let handle;
121
+ try {
122
+ handle = await open(path, "wx");
123
+ } catch (error) {
124
+ if (error.code === "EEXIST") {
125
+ return false;
126
+ }
127
+ throw error;
128
+ }
129
+ try {
130
+ await handle.writeFile(data, "utf8");
131
+ } finally {
132
+ await handle.close();
133
+ }
134
+ return true;
135
+ }
118
136
  var defaultFs = {
119
137
  async fileExists(path) {
120
138
  try {
@@ -127,7 +145,11 @@ var defaultFs = {
127
145
  readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
128
146
  readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
129
147
  writeFile: (path, data) => atomicWrite(path, data),
130
- writeBytes: (path, data) => atomicWrite(path, data)
148
+ writeBytes: (path, data) => atomicWrite(path, data),
149
+ createExclusive: (path, data) => createExclusive(path, data),
150
+ deleteFile: async (path) => {
151
+ await rm(path, { force: true });
152
+ }
131
153
  };
132
154
  var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
133
155
  var glossaryRecordSchema = z.record(z.string(), z.string());
@@ -316,29 +338,18 @@ var TIMEOUT_STATUS = 408;
316
338
  var AUTH_FAILED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
317
339
  var RATE_LIMITED_CLASS_NAMES = /* @__PURE__ */ new Set([
318
340
  "RateLimitError",
319
- // openai, @anthropic-ai/sdk
320
341
  "TooManyRequestsError"
321
- // deepl-node
322
342
  ]);
323
343
  var AUTH_FAILED_CLASS_NAMES = /* @__PURE__ */ new Set([
324
344
  "AuthenticationError",
325
- // openai, @anthropic-ai/sdk (401)
326
345
  "PermissionDeniedError",
327
- // openai, @anthropic-ai/sdk (403)
328
346
  "AuthorizationError"
329
- // deepl-node
330
347
  ]);
331
348
  var TIMEOUT_CLASS_NAMES = /* @__PURE__ */ new Set([
332
349
  "APIConnectionTimeoutError",
333
- // openai, @anthropic-ai/sdk
334
- // deepl-node has no distinct timeout class; ConnectionError is its only network-failure
335
- // class, so it is folded into TIMEOUT rather than the PROVIDER_ERROR fallback.
336
350
  "ConnectionError"
337
351
  ]);
338
- var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set([
339
- "APIUserAbortError"
340
- // openai, @anthropic-ai/sdk
341
- ]);
352
+ var ABORT_ERROR_CLASS_NAMES = /* @__PURE__ */ new Set(["APIUserAbortError"]);
342
353
  function getErrorStatus(error) {
343
354
  if (typeof error !== "object" || error === null || !("status" in error)) {
344
355
  return void 0;
@@ -392,7 +403,6 @@ function isAbortError(error, signal) {
392
403
  }
393
404
  var REDACTED = "[REDACTED]";
394
405
  var KEY_PATTERNS = [
395
- // The `\b` anchors `sk-` to a word start so hyphenated words like "risk-" or "task-" pass through.
396
406
  /\bsk-[A-Za-z0-9_-]{8,}/g,
397
407
  /AIza[0-9A-Za-z_-]{35}/g,
398
408
  /[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
@@ -414,6 +424,10 @@ var ProviderError = class extends Error {
414
424
  /** The stable {@link ProviderErrorCode} for this failure; branch on this, not the message. */
415
425
  code;
416
426
  /**
427
+ * Redacts the message as a defense-in-depth backstop before storing it. The redaction runs with an
428
+ * empty secret argument so the ANTHROPIC_API_KEY environment default is not re-applied, keeping
429
+ * this generic error decoupled from any one provider's environment variable.
430
+ *
417
431
  * @param code - The stable failure code.
418
432
  * @param message - A fixed, safe message; callers must never pass key, SDK, or request-derived text.
419
433
  */
@@ -472,6 +486,76 @@ function validateRequest(request) {
472
486
  }
473
487
  return parsed.data;
474
488
  }
489
+ var LENGTH_RATIO_MIN = 0.35;
490
+ var LENGTH_RATIO_MAX = 3;
491
+ var LENGTH_RATIO_MIN_SOURCE_LENGTH = 12;
492
+ var UNICODE_LETTER = /\p{L}/u;
493
+ var DEGRADATION_NOTICE_CODES = /* @__PURE__ */ new Set([
494
+ "FORMALITY_DOWNGRADED",
495
+ "GLOSSARY_IGNORED"
496
+ ]);
497
+ function isLengthRatioOutlier(sourceValue, translatedValue) {
498
+ const trimmedSource = sourceValue.trim();
499
+ if (trimmedSource.length < LENGTH_RATIO_MIN_SOURCE_LENGTH) {
500
+ return false;
501
+ }
502
+ const ratio = translatedValue.trim().length / trimmedSource.length;
503
+ return ratio < LENGTH_RATIO_MIN || ratio > LENGTH_RATIO_MAX;
504
+ }
505
+ function isEqualsSource(input) {
506
+ const trimmedSource = input.sourceValue.trim();
507
+ const trimmedTranslated = input.translatedValue.trim();
508
+ return trimmedTranslated === trimmedSource && input.targetLocale !== input.sourceLocale && UNICODE_LETTER.test(trimmedSource);
509
+ }
510
+ function isGlossaryTermMissed(input) {
511
+ const glossary = input.glossary;
512
+ if (glossary === void 0 || Object.keys(glossary).length === 0) {
513
+ return false;
514
+ }
515
+ const sourceLower = input.sourceValue.toLowerCase();
516
+ const translatedLower = input.translatedValue.toLowerCase();
517
+ for (const [sourceTerm, targetTerm] of Object.entries(glossary)) {
518
+ const sourceHit = sourceLower.includes(sourceTerm.toLowerCase());
519
+ const targetHit = translatedLower.includes(targetTerm.toLowerCase());
520
+ if (sourceHit && !targetHit) {
521
+ return true;
522
+ }
523
+ }
524
+ return false;
525
+ }
526
+ function isIntegrityReordered(integrity) {
527
+ return integrity.matches && integrity.reordered;
528
+ }
529
+ function computeReviewFlags(input) {
530
+ const reasons = [];
531
+ if (isLengthRatioOutlier(input.sourceValue, input.translatedValue)) {
532
+ reasons.push("LENGTH_RATIO_OUTLIER");
533
+ }
534
+ if (isEqualsSource(input)) {
535
+ reasons.push("EQUALS_SOURCE");
536
+ }
537
+ if (isGlossaryTermMissed(input)) {
538
+ reasons.push("GLOSSARY_TERM_MISSED");
539
+ }
540
+ if (isIntegrityReordered(input.integrity)) {
541
+ reasons.push("INTEGRITY_REORDERED");
542
+ }
543
+ return reasons.length > 0 ? { status: "review", reasons } : void 0;
544
+ }
545
+ function applyProviderDegraded(reviewFlags, notices, acceptedKeys) {
546
+ if (!notices.some((notice) => DEGRADATION_NOTICE_CODES.has(notice.code))) {
547
+ return reviewFlags;
548
+ }
549
+ const next = new Map(reviewFlags);
550
+ for (const key of acceptedKeys) {
551
+ const existing = next.get(key);
552
+ next.set(key, {
553
+ status: "review",
554
+ reasons: existing !== void 0 ? [...existing.reasons, "PROVIDER_DEGRADED"] : ["PROVIDER_DEGRADED"]
555
+ });
556
+ }
557
+ return next;
558
+ }
475
559
  function toIntegrityInputs(entries, values) {
476
560
  const inputs = [];
477
561
  for (const entry of entries) {
@@ -567,7 +651,33 @@ async function runLlmTranslation(request, mechanism) {
567
651
  request.extractPlaceholders,
568
652
  request.comparePlaceholders
569
653
  );
570
- return usage === void 0 ? { values, integrity, notices: [] } : { values, integrity, usage, notices: [] };
654
+ const notices = [];
655
+ const reviewFlags = applyProviderDegraded(buildReviewFlags(data, values, integrity), notices, [
656
+ ...values.keys()
657
+ ]);
658
+ return usage === void 0 ? { values, integrity, notices, reviewFlags } : { values, integrity, usage, notices, reviewFlags };
659
+ }
660
+ function buildReviewFlags(data, values, integrity) {
661
+ const reviewFlags = /* @__PURE__ */ new Map();
662
+ for (const entry of data.entries) {
663
+ const translatedValue = values.get(entry.key);
664
+ const entryIntegrity = integrity.get(entry.key);
665
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
666
+ continue;
667
+ }
668
+ const flag = computeReviewFlags({
669
+ sourceValue: entry.value,
670
+ translatedValue,
671
+ sourceLocale: data.sourceLocale,
672
+ targetLocale: data.targetLocale,
673
+ integrity: entryIntegrity,
674
+ glossary: data.glossary
675
+ });
676
+ if (flag !== void 0) {
677
+ reviewFlags.set(entry.key, flag);
678
+ }
679
+ }
680
+ return reviewFlags;
571
681
  }
572
682
  async function requestTranslations(mechanism, data, signal) {
573
683
  const payloadJson = JSON.stringify(buildDataPayload(data));
@@ -885,9 +995,6 @@ function createDeepLProvider(config, deps = {}) {
885
995
  return {
886
996
  id: PROVIDER_ID2,
887
997
  kind: "machine-translation",
888
- // Honest capability signal: DeepL only ever applies a pre-created native glossary id, never a
889
- // generic term map (see buildTranslateOptions). Without one configured, a supplied term map is
890
- // silently ignored and surfaces only as a GLOSSARY_IGNORED notice.
891
998
  supportsGlossary: validConfig.glossaryId !== void 0,
892
999
  translateBatch: (request) => translate(bundle, validConfig, request)
893
1000
  };
@@ -922,7 +1029,34 @@ async function translate(bundle, config, request) {
922
1029
  if (unprotectable.length > 0) {
923
1030
  notices.push({ code: "PLACEHOLDER_UNSUPPORTED", message: PLACEHOLDER_UNSUPPORTED_MESSAGE });
924
1031
  }
925
- return { values, integrity, notices };
1032
+ const reviewFlags = applyProviderDegraded(
1033
+ buildReviewFlags2(protectable, data, values, integrity, request.glossary),
1034
+ notices,
1035
+ [...values.keys()]
1036
+ );
1037
+ return { values, integrity, notices, reviewFlags };
1038
+ }
1039
+ function buildReviewFlags2(protectable, data, values, integrity, glossary) {
1040
+ const reviewFlags = /* @__PURE__ */ new Map();
1041
+ for (const entry of protectable) {
1042
+ const translatedValue = values.get(entry.key);
1043
+ const entryIntegrity = integrity.get(entry.key);
1044
+ if (translatedValue === void 0 || entryIntegrity === void 0) {
1045
+ continue;
1046
+ }
1047
+ const flag = computeReviewFlags({
1048
+ sourceValue: entry.value,
1049
+ translatedValue,
1050
+ sourceLocale: data.sourceLocale,
1051
+ targetLocale: data.targetLocale,
1052
+ integrity: entryIntegrity,
1053
+ glossary
1054
+ });
1055
+ if (flag !== void 0) {
1056
+ reviewFlags.set(entry.key, flag);
1057
+ }
1058
+ }
1059
+ return reviewFlags;
926
1060
  }
927
1061
  async function translateProtectable(client, data, protectable, options, extract, compare, signal) {
928
1062
  if (protectable.length === 0) {
@@ -972,14 +1106,14 @@ function throwAbort(signal) {
972
1106
  throw new DOMException("This operation was aborted.", "AbortError");
973
1107
  }
974
1108
  function delay(ms, signal) {
975
- return new Promise((resolve7) => {
1109
+ return new Promise((resolve9) => {
976
1110
  const onAbort = () => {
977
1111
  clearTimeout(timer);
978
- resolve7();
1112
+ resolve9();
979
1113
  };
980
1114
  const timer = setTimeout(() => {
981
1115
  signal?.removeEventListener("abort", onAbort);
982
- resolve7();
1116
+ resolve9();
983
1117
  }, ms);
984
1118
  signal?.addEventListener("abort", onAbort, { once: true });
985
1119
  });
@@ -1027,12 +1161,10 @@ var TYPE_MAP = {
1027
1161
  object: "OBJECT"
1028
1162
  };
1029
1163
  var HANDLED_KEYWORDS = /* @__PURE__ */ new Set([
1030
- // transformed or recursed
1031
1164
  "type",
1032
1165
  "required",
1033
1166
  "properties",
1034
1167
  "items",
1035
- // deliberately dropped (not part of Google's Schema dialect)
1036
1168
  "$schema",
1037
1169
  "additionalProperties"
1038
1170
  ]);
@@ -1426,6 +1558,7 @@ function buildProvider(config) {
1426
1558
 
1427
1559
  // src/config/schema.ts
1428
1560
  var DEFAULT_MAX_BATCH_SIZE = 50;
1561
+ var DEFAULT_BUDGET_BEHAVIOR = "warn";
1429
1562
  function findCaseInsensitiveDuplicate(locales) {
1430
1563
  const seen = /* @__PURE__ */ new Set();
1431
1564
  for (const locale of locales) {
@@ -1474,7 +1607,23 @@ var verbatraConfigSchema = z.strictObject({
1474
1607
  * be a positive integer (zero, negative, or non-integer is rejected, never coerced). When absent,
1475
1608
  * {@link DEFAULT_MAX_BATCH_SIZE} applies.
1476
1609
  */
1477
- maxBatchSize: z.number().int().positive().optional()
1610
+ maxBatchSize: z.number().int().positive().optional(),
1611
+ /**
1612
+ * Optional whole-run token ceiling (input plus output tokens summed across every provider call, main
1613
+ * translation and plural generation alike, across all target locales). Checked after each completed
1614
+ * sub-batch, never mid-batch: the sub-batch whose completion crosses the ceiling is retained and
1615
+ * counted, since a call already in flight cannot be undone. Must be a positive integer. Config-only,
1616
+ * no CLI flag. Absent means no budget is enforced. Inert (never a false trip) against a token-less
1617
+ * provider such as DeepL, since it never reports usage to measure against the ceiling.
1618
+ */
1619
+ maxTokens: z.number().int().positive().optional(),
1620
+ /**
1621
+ * What happens once `maxTokens` is reached: `"warn"` (default) flags it and lets the run continue
1622
+ * unchanged; `"stop"` withholds every not-yet-attempted key for the rest of the run (the current
1623
+ * locale's remaining candidates and every later locale's), retried automatically next run. Present
1624
+ * without `maxTokens` is accepted and has no effect. Never changes the command's exit code.
1625
+ */
1626
+ budgetBehavior: z.enum(["warn", "stop"]).optional()
1478
1627
  }).refine((config) => !config.targetLocales.includes(config.sourceLocale), {
1479
1628
  message: "targetLocales must not include the source locale",
1480
1629
  path: ["targetLocales"]
@@ -1583,6 +1732,60 @@ async function loadConfig(options = {}) {
1583
1732
  const { config } = await loadConfigWithMeta(options);
1584
1733
  return config;
1585
1734
  }
1735
+ var LOCAL_DIR_NAME = ".verbatra-local";
1736
+ var LOCK_FILE_GUARD_STEM = "_lockfile";
1737
+ var DEFAULT_POLL_INTERVAL_MS = 100;
1738
+ var DEFAULT_ACQUIRE_TIMEOUT_MS = 10 * 6e4;
1739
+ function lockPath(cwd, stem) {
1740
+ return resolve(cwd, LOCAL_DIR_NAME, "locks", `${stem}.lock`);
1741
+ }
1742
+ function localeLockPath(cwd, locale) {
1743
+ return lockPath(cwd, locale);
1744
+ }
1745
+ function lockFileGuardPath(cwd) {
1746
+ return lockPath(cwd, LOCK_FILE_GUARD_STEM);
1747
+ }
1748
+ function sleep(ms) {
1749
+ return new Promise((res) => {
1750
+ setTimeout(res, ms);
1751
+ });
1752
+ }
1753
+ function lockPayload() {
1754
+ return JSON.stringify({ pid: process.pid, acquiredAt: (/* @__PURE__ */ new Date()).toISOString() });
1755
+ }
1756
+ async function acquireLock(path, fs, pollIntervalMs, deadline) {
1757
+ for (; ; ) {
1758
+ if (await fs.createExclusive(path, lockPayload())) {
1759
+ return;
1760
+ }
1761
+ if (Date.now() >= deadline) {
1762
+ throw new SdkError(
1763
+ "LOCK_CONTENDED",
1764
+ `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.`
1765
+ );
1766
+ }
1767
+ const jitter = Math.random() * pollIntervalMs;
1768
+ await sleep(pollIntervalMs + jitter);
1769
+ }
1770
+ }
1771
+ async function withFileLock(path, fs, fn, options) {
1772
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1773
+ const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
1774
+ await acquireLock(path, fs, pollIntervalMs, Date.now() + acquireTimeoutMs);
1775
+ try {
1776
+ return await fn();
1777
+ } finally {
1778
+ await fs.deleteFile(path);
1779
+ }
1780
+ }
1781
+ async function withLocaleWriteLock(cwd, locale, fs, fn, options = {}) {
1782
+ return withFileLock(localeLockPath(cwd, locale), fs, fn, options);
1783
+ }
1784
+ async function withLockFileGuard(cwd, fs, fn, options = {}) {
1785
+ return withFileLock(lockFileGuardPath(cwd), fs, fn, options);
1786
+ }
1787
+
1788
+ // src/lock/lock-file.ts
1586
1789
  var LOCK_FILE_NAME = "verbatra.lock.json";
1587
1790
  var CURRENT_VERSION = 1;
1588
1791
  var EMPTY_LOCK = { version: CURRENT_VERSION, locales: {} };
@@ -1594,8 +1797,7 @@ var lockFileSchema = z.object({
1594
1797
  function lockFilePath(cwd) {
1595
1798
  return resolve(cwd, LOCK_FILE_NAME);
1596
1799
  }
1597
- async function readLockFile(path, fs) {
1598
- const read = await fs.readFileBounded(path, MAX_LOCK_FILE_BYTES);
1800
+ function parseLockFileRead(read, path) {
1599
1801
  if (read.kind === "missing") {
1600
1802
  return EMPTY_LOCK;
1601
1803
  }
@@ -1623,6 +1825,9 @@ async function readLockFile(path, fs) {
1623
1825
  }
1624
1826
  return result.data;
1625
1827
  }
1828
+ async function readLockFile(path, fs) {
1829
+ return parseLockFileRead(await fs.readFileBounded(path, MAX_LOCK_FILE_BYTES), path);
1830
+ }
1626
1831
  function baselineFor(lock, locale) {
1627
1832
  return new Map(Object.entries(lock.locales[locale] ?? {}));
1628
1833
  }
@@ -1638,14 +1843,30 @@ function byKey(a, b) {
1638
1843
  function sortRecord(record) {
1639
1844
  return Object.fromEntries(Object.entries(record).sort(byKey));
1640
1845
  }
1641
- async function writeLockFile(path, lock, fs) {
1846
+ function serializeLockFile(lock) {
1642
1847
  const locales = {};
1643
1848
  for (const [locale, entries] of Object.entries(lock.locales).sort(byKey)) {
1644
1849
  locales[locale] = sortRecord(entries);
1645
1850
  }
1646
1851
  const ordered = { version: lock.version, locales };
1647
- await fs.writeFile(path, `${JSON.stringify(ordered, null, 2)}
1648
- `);
1852
+ return `${JSON.stringify(ordered, null, 2)}
1853
+ `;
1854
+ }
1855
+ function applyLockLocalePatch(currentEntries, patch) {
1856
+ if (patch.mode === "replace") {
1857
+ return patch.entries;
1858
+ }
1859
+ return { ...currentEntries, ...patch.entries };
1860
+ }
1861
+ async function updateLockFileLocale(cwd, fs, locale, patch) {
1862
+ return withLockFileGuard(cwd, fs, async () => {
1863
+ const path = lockFilePath(cwd);
1864
+ const lock = parseLockFileRead(await fs.readFileBounded(path, MAX_LOCK_FILE_BYTES), path);
1865
+ const nextEntries = applyLockLocalePatch(lock.locales[locale], patch);
1866
+ const next = updateLockLocale(lock, locale, nextEntries);
1867
+ await fs.writeFile(path, serializeLockFile(next));
1868
+ return next;
1869
+ });
1649
1870
  }
1650
1871
  var VALID_EMPTY = { placeholders: [], isPlural: false, valid: true };
1651
1872
  var INVALID = { placeholders: [], isPlural: false, valid: false };
@@ -1907,10 +2128,68 @@ var AdapterError = class extends Error {
1907
2128
  };
1908
2129
  var MAX_DEPTH = 100;
1909
2130
  var MAX_INPUT_BYTES = 16 * 1024 * 1024;
1910
- var jsonTreeSchema = z.lazy(
1911
- () => z.union([z.string(), z.record(z.string(), jsonTreeSchema)])
1912
- );
1913
- var rootSchema = z.record(z.string(), jsonTreeSchema);
2131
+ var SENTINEL_ESCAPE = "\\u0001";
2132
+ function isJsonWhitespace(char) {
2133
+ return char === " " || char === " " || char === "\n" || char === "\r";
2134
+ }
2135
+ function endOfStringToken(content, openQuote) {
2136
+ let index = openQuote + 1;
2137
+ while (index < content.length) {
2138
+ const char = content[index];
2139
+ if (char === "\\") {
2140
+ index += 2;
2141
+ } else if (char === '"') {
2142
+ return index + 1;
2143
+ } else {
2144
+ index += 1;
2145
+ }
2146
+ }
2147
+ return index;
2148
+ }
2149
+ function isKeyToken(content, end) {
2150
+ let index = end;
2151
+ while (isJsonWhitespace(content[index])) {
2152
+ index += 1;
2153
+ }
2154
+ return content[index] === ":";
2155
+ }
2156
+ function prefixKeyToken(token) {
2157
+ if (token.includes(SENTINEL_ESCAPE)) {
2158
+ throw new AdapterError(
2159
+ "INVALID_STRUCTURE",
2160
+ "A key contains a reserved control-character escape."
2161
+ );
2162
+ }
2163
+ return `"${SENTINEL_ESCAPE}${token.slice(1)}`;
2164
+ }
2165
+ function prefixKeyTokens(content) {
2166
+ let out = "";
2167
+ let index = 0;
2168
+ while (index < content.length) {
2169
+ const openQuote = content.indexOf('"', index);
2170
+ if (openQuote === -1) {
2171
+ return out + content.slice(index);
2172
+ }
2173
+ const end = endOfStringToken(content, openQuote);
2174
+ const token = content.slice(openQuote, end);
2175
+ out += content.slice(index, openQuote);
2176
+ out += isKeyToken(content, end) ? prefixKeyToken(token) : token;
2177
+ index = end;
2178
+ }
2179
+ return out;
2180
+ }
2181
+ function childrenOf(node) {
2182
+ if (node instanceof Map) {
2183
+ return node.values();
2184
+ }
2185
+ if (Array.isArray(node)) {
2186
+ return node;
2187
+ }
2188
+ if (typeof node === "object" && node !== null) {
2189
+ return Object.values(node);
2190
+ }
2191
+ return null;
2192
+ }
1914
2193
  function assertWithinDepth(value, max) {
1915
2194
  const stack = [{ node: value, depth: 1 }];
1916
2195
  while (stack.length > 0) {
@@ -1918,42 +2197,118 @@ function assertWithinDepth(value, max) {
1918
2197
  if (top === void 0) {
1919
2198
  break;
1920
2199
  }
1921
- const { node, depth } = top;
1922
- if (typeof node !== "object" || node === null) {
2200
+ const children = childrenOf(top.node);
2201
+ if (children === null) {
1923
2202
  continue;
1924
2203
  }
1925
- if (depth > max) {
2204
+ if (top.depth > max) {
1926
2205
  throw new AdapterError("MAX_DEPTH_EXCEEDED", "The file nests objects too deeply.");
1927
2206
  }
1928
- for (const child of Object.values(node)) {
1929
- stack.push({ node: child, depth: depth + 1 });
2207
+ for (const child of children) {
2208
+ stack.push({ node: child, depth: top.depth + 1 });
1930
2209
  }
1931
2210
  }
1932
2211
  }
1933
- function assertJsonRecord(value) {
1934
- assertWithinDepth(value, MAX_DEPTH);
1935
- const result = rootSchema.safeParse(value);
1936
- if (!result.success) {
1937
- throw new AdapterError(
1938
- "INVALID_STRUCTURE",
1939
- "The file is not a valid object (expected nested objects of string values)."
1940
- );
2212
+ function toOrdered(node) {
2213
+ if (Array.isArray(node)) {
2214
+ return node.map((child) => toOrdered(child));
1941
2215
  }
1942
- return result.data;
2216
+ if (typeof node === "object" && node !== null) {
2217
+ const out = /* @__PURE__ */ new Map();
2218
+ for (const [key, child] of Object.entries(node)) {
2219
+ out.set(key.slice(1), toOrdered(child));
2220
+ }
2221
+ return out;
2222
+ }
2223
+ return node;
1943
2224
  }
1944
- function parseJsonObject(content) {
2225
+ function parseOrderedJson(content) {
2226
+ const prefixed = prefixKeyTokens(content);
1945
2227
  let parsed;
1946
2228
  try {
1947
- parsed = JSON.parse(content);
2229
+ parsed = JSON.parse(prefixed);
1948
2230
  } catch {
1949
2231
  throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
1950
2232
  }
1951
- return assertJsonRecord(parsed);
2233
+ assertWithinDepth(parsed, MAX_DEPTH);
2234
+ return toOrdered(parsed);
1952
2235
  }
1953
- function serializeJsonTree(tree) {
1954
- return `${JSON.stringify(tree, null, 2)}
2236
+ function printRecord(record, indent) {
2237
+ if (record.size === 0) {
2238
+ return "{}";
2239
+ }
2240
+ const childIndent = `${indent} `;
2241
+ const lines = [];
2242
+ for (const [key, child] of record) {
2243
+ lines.push(`${childIndent}${JSON.stringify(key)}: ${printValue(child, childIndent)}`);
2244
+ }
2245
+ return `{
2246
+ ${lines.join(",\n")}
2247
+ ${indent}}`;
2248
+ }
2249
+ function printArray(items, indent) {
2250
+ if (items.length === 0) {
2251
+ return "[]";
2252
+ }
2253
+ const childIndent = `${indent} `;
2254
+ const lines = items.map((item) => `${childIndent}${printValue(item, childIndent)}`);
2255
+ return `[
2256
+ ${lines.join(",\n")}
2257
+ ${indent}]`;
2258
+ }
2259
+ function printValue(value, indent) {
2260
+ if (value instanceof Map) {
2261
+ return printRecord(value, indent);
2262
+ }
2263
+ if (Array.isArray(value)) {
2264
+ return printArray(value, indent);
2265
+ }
2266
+ return JSON.stringify(value);
2267
+ }
2268
+ function serializeOrderedJson(value) {
2269
+ return `${printValue(value, "")}
1955
2270
  `;
1956
2271
  }
2272
+ var INVALID_STRUCTURE_MESSAGE = "The file is not a valid object (expected nested objects of string, number, boolean, or null leaves).";
2273
+ function isJsonLeaf(value) {
2274
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null;
2275
+ }
2276
+ function assertNodesValid(root) {
2277
+ const stack = [root];
2278
+ while (stack.length > 0) {
2279
+ const node = stack.pop();
2280
+ if (node === void 0) {
2281
+ break;
2282
+ }
2283
+ for (const [key, child] of node) {
2284
+ if (typeof key !== "string") {
2285
+ throw new AdapterError("INVALID_STRUCTURE", INVALID_STRUCTURE_MESSAGE);
2286
+ }
2287
+ if (child instanceof Map) {
2288
+ stack.push(child);
2289
+ } else if (!isJsonLeaf(child)) {
2290
+ throw new AdapterError("INVALID_STRUCTURE", INVALID_STRUCTURE_MESSAGE);
2291
+ }
2292
+ }
2293
+ }
2294
+ }
2295
+ function assertJsonRecord(value) {
2296
+ assertWithinDepth(value, MAX_DEPTH);
2297
+ if (!(value instanceof Map)) {
2298
+ throw new AdapterError("INVALID_STRUCTURE", INVALID_STRUCTURE_MESSAGE);
2299
+ }
2300
+ assertNodesValid(value);
2301
+ return value;
2302
+ }
2303
+ function parseJsonObject(content) {
2304
+ return assertJsonRecord(parseOrderedJson(content));
2305
+ }
2306
+ function serializeJsonTree(tree) {
2307
+ return serializeOrderedJson(tree);
2308
+ }
2309
+ function isJsonNode(value) {
2310
+ return value instanceof Map;
2311
+ }
1957
2312
  function namespaceOf(filePath) {
1958
2313
  return basename(filePath, extname(filePath));
1959
2314
  }
@@ -1981,9 +2336,25 @@ function buildCanHandle(extensions, sniff) {
1981
2336
  return sample === void 0 || sniff === void 0 || sniff(sample);
1982
2337
  };
1983
2338
  }
2339
+ async function fsyncPath(path) {
2340
+ const handle = await open(path, "r");
2341
+ try {
2342
+ await handle.sync();
2343
+ } finally {
2344
+ await handle.close();
2345
+ }
2346
+ }
2347
+ async function fsyncDirBestEffort(path) {
2348
+ try {
2349
+ await fsyncPath(path);
2350
+ } catch {
2351
+ }
2352
+ }
1984
2353
  var nodeOps = {
1985
2354
  writeFile: (path, data) => writeFile(path, data, "utf8"),
2355
+ fsyncFile: (path) => fsyncPath(path),
1986
2356
  rename: (from, to) => rename(from, to),
2357
+ fsyncDir: (path) => fsyncDirBestEffort(path),
1987
2358
  rm: (path) => rm(path, { force: true })
1988
2359
  };
1989
2360
  async function cleanup(ops, tmp) {
@@ -1999,11 +2370,16 @@ async function atomicWriteFile(path, data, ops = nodeOps) {
1999
2370
  const tmp = tempFileName2(path);
2000
2371
  try {
2001
2372
  await ops.writeFile(tmp, data);
2373
+ await ops.fsyncFile(tmp);
2002
2374
  await ops.rename(tmp, path);
2003
2375
  } catch (error) {
2004
2376
  await cleanup(ops, tmp);
2005
2377
  throw error;
2006
2378
  }
2379
+ try {
2380
+ await ops.fsyncDir(dirname(path));
2381
+ } catch {
2382
+ }
2007
2383
  }
2008
2384
  function stripLeadingBom(content) {
2009
2385
  return content.charCodeAt(0) === 65279 ? content.slice(1) : content;
@@ -2125,21 +2501,25 @@ function addLeaf(ctx, segments, key, value) {
2125
2501
  );
2126
2502
  }
2127
2503
  ctx.claimed.set(effectivePath, mapKey);
2504
+ if (typeof value !== "string") {
2505
+ ctx.excluded.push(mapKey);
2506
+ return;
2507
+ }
2128
2508
  const { placeholders, isPlural } = ctx.derive(key, value);
2129
2509
  ctx.out.set(mapKey, { key: mapKey, namespace: ctx.namespace, value, placeholders, isPlural });
2130
2510
  }
2131
2511
  function addEntries(ctx, prefix, node) {
2132
- for (const [key, value] of Object.entries(node)) {
2512
+ for (const [key, value] of node) {
2133
2513
  const segments = [...prefix, key];
2134
- if (typeof value === "string") {
2135
- addLeaf(ctx, segments, key, value);
2136
- } else {
2514
+ if (isJsonNode(value)) {
2137
2515
  addEntries(ctx, segments, value);
2516
+ } else {
2517
+ addLeaf(ctx, segments, key, value);
2138
2518
  }
2139
2519
  }
2140
2520
  }
2141
- function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2142
- for (const [key, value] of Object.entries(node)) {
2521
+ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths, excluded) {
2522
+ for (const [key, value] of node) {
2143
2523
  const path = prefix === "" ? key : `${prefix}.${key}`;
2144
2524
  if (claimedPaths.has(path)) {
2145
2525
  throw new AdapterError(
@@ -2148,34 +2528,35 @@ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
2148
2528
  );
2149
2529
  }
2150
2530
  claimedPaths.add(path);
2151
- if (typeof value === "string") {
2531
+ if (isJsonNode(value)) {
2532
+ addPathEntries(value, path, namespace, derive, out, claimedPaths, excluded);
2533
+ } else if (typeof value === "string") {
2152
2534
  const { placeholders, isPlural } = derive(key, value);
2153
2535
  out.set(path, { key: path, namespace, value, placeholders, isPlural });
2154
2536
  } else {
2155
- addPathEntries(value, path, namespace, derive, out, claimedPaths);
2537
+ excluded.push(path);
2156
2538
  }
2157
2539
  }
2158
2540
  }
2159
2541
  function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
2160
2542
  const out = /* @__PURE__ */ new Map();
2161
2543
  if (keyMode === "path-notation") {
2162
- addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set());
2163
- return out;
2544
+ const excluded = [];
2545
+ addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set(), excluded);
2546
+ return { entries: out, excludedLeafPaths: excluded };
2164
2547
  }
2165
- addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
2166
- return out;
2167
- }
2168
- function emptyNode() {
2169
- return /* @__PURE__ */ Object.create(null);
2548
+ const ctx = { namespace, derive, out, claimed: /* @__PURE__ */ new Map(), excluded: [] };
2549
+ addEntries(ctx, [], tree);
2550
+ return { entries: out, excludedLeafPaths: ctx.excluded };
2170
2551
  }
2171
2552
  function descend(node, segment) {
2172
- const next = node[segment];
2553
+ const next = node.get(segment);
2173
2554
  if (next === void 0) {
2174
- const created = emptyNode();
2175
- node[segment] = created;
2555
+ const created = /* @__PURE__ */ new Map();
2556
+ node.set(segment, created);
2176
2557
  return created;
2177
2558
  }
2178
- if (typeof next === "object") {
2559
+ if (next instanceof Map) {
2179
2560
  return next;
2180
2561
  }
2181
2562
  throw new AdapterError("INVALID_STRUCTURE", "A leaf key collides with a nested key path.");
@@ -2189,13 +2570,13 @@ function setPath(root, segments, value) {
2189
2570
  for (const segment of segments.slice(0, -1)) {
2190
2571
  node = descend(node, segment);
2191
2572
  }
2192
- if (typeof node[leaf] === "object") {
2573
+ if (node.get(leaf) instanceof Map) {
2193
2574
  throw new AdapterError("INVALID_STRUCTURE", "A leaf key collides with a nested key path.");
2194
2575
  }
2195
- node[leaf] = value;
2576
+ node.set(leaf, value);
2196
2577
  }
2197
2578
  function unflattenEntries(entries) {
2198
- const root = emptyNode();
2579
+ const root = /* @__PURE__ */ new Map();
2199
2580
  for (const [key, entry] of entries) {
2200
2581
  setPath(root, decodeKeyToSegments(key), entry.value);
2201
2582
  }
@@ -2216,9 +2597,9 @@ function toEntries(content, namespace, parse3, deriveEntry, keyMode, validateTre
2216
2597
  try {
2217
2598
  const tree = parse3(content);
2218
2599
  validateTree?.(tree);
2219
- const entries = flattenTree(tree, namespace, deriveEntry, keyMode);
2220
- mergeDescriptions(entries, content, deriveDescriptions);
2221
- return entries;
2600
+ const result = flattenTree(tree, namespace, deriveEntry, keyMode);
2601
+ mergeDescriptions(result.entries, content, deriveDescriptions);
2602
+ return result;
2222
2603
  } catch (error) {
2223
2604
  rethrowStructured(error, "The file could not be parsed.");
2224
2605
  }
@@ -2249,7 +2630,7 @@ function createTreeFileAdapter(options) {
2249
2630
  async read(filePath, locale) {
2250
2631
  const content = await readFileContent(filePath);
2251
2632
  const namespace = namespaceOf(filePath);
2252
- const entries = toEntries(
2633
+ const { entries, excludedLeafPaths } = toEntries(
2253
2634
  content,
2254
2635
  namespace,
2255
2636
  parse3,
@@ -2260,7 +2641,7 @@ function createTreeFileAdapter(options) {
2260
2641
  );
2261
2642
  const resource = { locale, namespace, format, entries };
2262
2643
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2263
- return { resource, invalidIcuKeys };
2644
+ return { resource, invalidIcuKeys, excludedLeafPaths };
2264
2645
  },
2265
2646
  async write(resource, filePath) {
2266
2647
  const tree = buildWriteTree ? await buildWriteTree(resource.entries, filePath) : unflattenEntries(resource.entries);
@@ -2272,13 +2653,8 @@ function isMetadataKey(key) {
2272
2653
  return key.startsWith("@");
2273
2654
  }
2274
2655
  function parseArbObject(content) {
2275
- let parsed;
2276
- try {
2277
- parsed = JSON.parse(content);
2278
- } catch {
2279
- throw new AdapterError("INVALID_JSON", "The file is not valid JSON.");
2280
- }
2281
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2656
+ const parsed = parseOrderedJson(content);
2657
+ if (!(parsed instanceof Map)) {
2282
2658
  throw new AdapterError(
2283
2659
  "INVALID_STRUCTURE",
2284
2660
  "The file is not a valid object (expected nested objects of string values)."
@@ -2287,10 +2663,10 @@ function parseArbObject(content) {
2287
2663
  return parsed;
2288
2664
  }
2289
2665
  function stripArbMetadata(tree) {
2290
- const out = /* @__PURE__ */ Object.create(null);
2291
- for (const [key, value] of Object.entries(tree)) {
2666
+ const out = /* @__PURE__ */ new Map();
2667
+ for (const [key, value] of tree) {
2292
2668
  if (!isMetadataKey(key)) {
2293
- out[key] = value;
2669
+ out.set(key, value);
2294
2670
  }
2295
2671
  }
2296
2672
  return out;
@@ -2302,16 +2678,16 @@ function messageKeyForMetadata(key) {
2302
2678
  return isMetadataKey(key) && !key.startsWith("@@") ? key.slice(1) : null;
2303
2679
  }
2304
2680
  function descriptionOf(value) {
2305
- if (typeof value !== "object" || value === null) {
2681
+ if (!(value instanceof Map)) {
2306
2682
  return void 0;
2307
2683
  }
2308
- const description = value.description;
2684
+ const description = value.get("description");
2309
2685
  return typeof description === "string" ? description : void 0;
2310
2686
  }
2311
2687
  function extractArbDescriptions(content) {
2312
2688
  const tree = parseArbObject(content);
2313
2689
  const out = /* @__PURE__ */ new Map();
2314
- for (const [key, value] of Object.entries(tree)) {
2690
+ for (const [key, value] of tree) {
2315
2691
  const messageKey = messageKeyForMetadata(key);
2316
2692
  if (messageKey === null) {
2317
2693
  continue;
@@ -2343,23 +2719,25 @@ async function readDestinationPairs(filePath) {
2343
2719
  }
2344
2720
  throw error;
2345
2721
  }
2346
- return Object.entries(parseArbObject(content));
2722
+ return [...parseArbObject(content)];
2347
2723
  }
2348
2724
  async function buildArbWriteTree(entries, filePath) {
2349
2725
  const messages = messagesFromEntries(entries);
2350
2726
  const pairs = await readDestinationPairs(filePath);
2351
- const out = /* @__PURE__ */ Object.create(null);
2727
+ const out = /* @__PURE__ */ new Map();
2352
2728
  const consumed = /* @__PURE__ */ new Set();
2353
2729
  for (const [key, value] of pairs ?? []) {
2354
2730
  const translated = isMetadataKey(key) ? void 0 : messages.get(key);
2355
2731
  if (translated !== void 0) {
2356
2732
  consumed.add(key);
2733
+ out.set(key, translated);
2734
+ } else if (isMetadataKey(key) || typeof value === "string") {
2735
+ out.set(key, value);
2357
2736
  }
2358
- out[key] = translated ?? value;
2359
2737
  }
2360
2738
  for (const [key, value] of messages) {
2361
2739
  if (!consumed.has(key)) {
2362
- out[key] = value;
2740
+ out.set(key, value);
2363
2741
  }
2364
2742
  }
2365
2743
  return out;
@@ -2448,8 +2826,8 @@ function createNextIntlJsonAdapter() {
2448
2826
  });
2449
2827
  }
2450
2828
  function assertNoDottedNestedKey(tree) {
2451
- for (const [key, value] of Object.entries(tree)) {
2452
- if (typeof value !== "object") {
2829
+ for (const [key, value] of tree) {
2830
+ if (!isJsonNode(value)) {
2453
2831
  continue;
2454
2832
  }
2455
2833
  if (key.includes(".")) {
@@ -2464,8 +2842,8 @@ function assertNoDottedNestedKey(tree) {
2464
2842
  function assertNotMixed(tree) {
2465
2843
  let hasNested = false;
2466
2844
  let hasFlatDottedKey = false;
2467
- for (const [key, value] of Object.entries(tree)) {
2468
- if (typeof value === "object") {
2845
+ for (const [key, value] of tree) {
2846
+ if (isJsonNode(value)) {
2469
2847
  hasNested = true;
2470
2848
  } else if (key.includes(".")) {
2471
2849
  hasFlatDottedKey = true;
@@ -2501,9 +2879,9 @@ async function detectStyle(filePath) {
2501
2879
  return "flat";
2502
2880
  }
2503
2881
  function buildFlatTree(entries) {
2504
- const out = /* @__PURE__ */ Object.create(null);
2882
+ const out = /* @__PURE__ */ new Map();
2505
2883
  for (const [key, entry] of entries) {
2506
- out[key] = entry.value;
2884
+ out.set(key, entry.value);
2507
2885
  }
2508
2886
  return out;
2509
2887
  }
@@ -2521,7 +2899,6 @@ function createNgxTranslateJsonAdapter() {
2521
2899
  }),
2522
2900
  validateTree: assertNotMixed,
2523
2901
  buildWriteTree: buildNgxWriteTree,
2524
- // ngx-translate flat style uses dotted keys as path notation, not literal leaves.
2525
2902
  keyMode: "path-notation"
2526
2903
  });
2527
2904
  }
@@ -2547,6 +2924,11 @@ var AdapterRegistry = class {
2547
2924
  }
2548
2925
  return { status: "resolved", adapter };
2549
2926
  }
2927
+ /**
2928
+ * Resolve by asking every registered adapter's `canHandle`. Several adapters can claim one file
2929
+ * (all JSON adapters claim `.json`), so more than one match is reported as `ambiguous` rather
2930
+ * than guessed at.
2931
+ */
2550
2932
  resolveByDetection(filePath, sample) {
2551
2933
  const matches = this.adapters.filter((adapter) => adapter.canHandle(filePath, sample));
2552
2934
  const first = matches[0];
@@ -2626,7 +3008,7 @@ function createFlatFileAdapter(options) {
2626
3008
  const entries = toEntries2(content, namespace, parseEntries);
2627
3009
  const resource = { locale, namespace, format, entries };
2628
3010
  const invalidIcuKeys = computeIcu(entries, computeInvalidIcuKeys);
2629
- return { resource, invalidIcuKeys };
3011
+ return { resource, invalidIcuKeys, excludedLeafPaths: [] };
2630
3012
  },
2631
3013
  async write(resource, filePath) {
2632
3014
  const data = await serializeEntries(resource.entries, filePath);
@@ -2901,14 +3283,34 @@ function createXliffAdapter() {
2901
3283
  extractPlaceholders: extractXliffPlaceholders
2902
3284
  });
2903
3285
  }
3286
+ function normalizeKey(key) {
3287
+ if (typeof key === "object" && key !== null) {
3288
+ throw new AdapterError(
3289
+ "INVALID_STRUCTURE",
3290
+ "A mapping key is a map or sequence (expected scalar keys)."
3291
+ );
3292
+ }
3293
+ return String(key);
3294
+ }
3295
+ function normalizeYamlTree(value) {
3296
+ if (!(value instanceof Map)) {
3297
+ return value;
3298
+ }
3299
+ const out = /* @__PURE__ */ new Map();
3300
+ for (const [key, child] of value) {
3301
+ out.set(normalizeKey(key), normalizeYamlTree(child));
3302
+ }
3303
+ return out;
3304
+ }
2904
3305
  function parseYamlObject(content) {
2905
3306
  let parsed;
2906
3307
  try {
2907
- parsed = parse$1(content, { maxAliasCount: 100 });
3308
+ parsed = parse$1(content, { mapAsMap: true, maxAliasCount: 100 });
2908
3309
  } catch {
2909
3310
  throw new AdapterError("INVALID_YAML", "The file is not valid YAML.");
2910
3311
  }
2911
- return assertJsonRecord(parsed);
3312
+ assertWithinDepth(parsed, MAX_DEPTH);
3313
+ return assertJsonRecord(normalizeYamlTree(parsed));
2912
3314
  }
2913
3315
  function createYamlAdapter() {
2914
3316
  return createTreeFileAdapter({
@@ -2939,6 +3341,15 @@ function selectAdapter(format, registry = createDefaultRegistry()) {
2939
3341
  );
2940
3342
  }
2941
3343
 
3344
+ // src/flow/read-target.ts
3345
+ async function readTargetResource(input) {
3346
+ const path = localeFilePath(input.cwd, input.filesPattern, input.locale);
3347
+ if (!await input.fs.fileExists(path)) {
3348
+ return { locale: input.locale, namespace: "", format: input.format, entries: /* @__PURE__ */ new Map() };
3349
+ }
3350
+ return (await input.adapter.read(path, input.locale)).resource;
3351
+ }
3352
+
2942
3353
  // src/flow/select-locales.ts
2943
3354
  function selectLocales(config, requested) {
2944
3355
  if (requested === void 0) {
@@ -2979,11 +3390,14 @@ async function readSource(config, cwd, fs, adapter) {
2979
3390
 
2980
3391
  // src/flow/diff-locales.ts
2981
3392
  async function readTarget(cwd, config, adapter, fs, locale) {
2982
- const path = localeFilePath(cwd, config.files.pattern, locale);
2983
- if (!await fs.fileExists(path)) {
2984
- return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
2985
- }
2986
- return (await adapter.read(path, locale)).resource;
3393
+ return readTargetResource({
3394
+ cwd,
3395
+ filesPattern: config.files.pattern,
3396
+ format: config.format,
3397
+ locale,
3398
+ adapter,
3399
+ fs
3400
+ });
2987
3401
  }
2988
3402
  async function diffLocales(input, deps = {}) {
2989
3403
  const config = input.config;
@@ -3033,6 +3447,166 @@ async function diff(input, deps = {}) {
3033
3447
  return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
3034
3448
  }
3035
3449
 
3450
+ // src/flow/integrity-gate.ts
3451
+ function gateCandidateValue(sourceEntry, candidateValue, adapter) {
3452
+ const placeholderResult = adapter.comparePlaceholders?.(sourceEntry.value, candidateValue) ?? checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(candidateValue));
3453
+ if (!placeholderResult.matches) {
3454
+ return { accepted: false, reason: "placeholder" };
3455
+ }
3456
+ if (!adapter.validateMessage(candidateValue)) {
3457
+ return { accepted: false, reason: "icu" };
3458
+ }
3459
+ return { accepted: true };
3460
+ }
3461
+
3462
+ // src/flow/edit-entry.ts
3463
+ async function editEntry(input, deps = {}) {
3464
+ const config = input.config;
3465
+ const cwd = input.cwd ?? process.cwd();
3466
+ const fs = deps.fs ?? defaultFs;
3467
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
3468
+ const [locale] = selectLocales(config, [input.locale]);
3469
+ if (locale === void 0) {
3470
+ throw new SdkError("UNKNOWN_LOCALE", `Locale "${input.locale}" could not be resolved.`);
3471
+ }
3472
+ const source = await readSource(config, cwd, fs, adapter);
3473
+ const sourceEntry = source.resource.entries.get(input.key);
3474
+ if (sourceEntry === void 0) {
3475
+ throw new SdkError(
3476
+ "UNKNOWN_KEY",
3477
+ `The key "${input.key}" was not found in the source resource.`
3478
+ );
3479
+ }
3480
+ return withLocaleWriteLock(cwd, locale, fs, async () => {
3481
+ const target = await readTarget(cwd, config, adapter, fs, locale);
3482
+ const gate = gateCandidateValue(sourceEntry, input.value, adapter);
3483
+ if (!gate.accepted) {
3484
+ return { accepted: false, reason: gate.reason, value: input.value };
3485
+ }
3486
+ const merged = new Map(target.entries);
3487
+ merged.set(input.key, { ...sourceEntry, value: input.value, namespace: target.namespace });
3488
+ const path = localeFilePath(cwd, config.files.pattern, locale);
3489
+ await adapter.write(
3490
+ { locale, namespace: target.namespace, format: config.format, entries: merged },
3491
+ path
3492
+ );
3493
+ await updateLockFileLocale(cwd, fs, locale, {
3494
+ mode: "merge",
3495
+ entries: { [input.key]: contentHash(sourceEntry) }
3496
+ });
3497
+ return { accepted: true, value: input.value };
3498
+ });
3499
+ }
3500
+
3501
+ // src/flow/key-integrity.ts
3502
+ function checkEntryIntegrity(adapter, sourceEntry, targetEntry) {
3503
+ const result = adapter.comparePlaceholders?.(sourceEntry.value, targetEntry.value) ?? checkPlaceholders(sourceEntry.placeholders, targetEntry.placeholders);
3504
+ return {
3505
+ key: sourceEntry.key,
3506
+ hasPlaceholders: sourceEntry.placeholders.length > 0,
3507
+ matches: result.matches,
3508
+ missing: result.missing,
3509
+ extra: result.extra,
3510
+ icuValid: adapter.validateMessage(targetEntry.value)
3511
+ };
3512
+ }
3513
+ function selectChangedKeys(changed, requested) {
3514
+ if (requested === void 0) {
3515
+ return changed;
3516
+ }
3517
+ const wanted = new Set(requested);
3518
+ return changed.filter((key) => wanted.has(key));
3519
+ }
3520
+ function integrityEntriesFor(source, target, adapter, changedKeys) {
3521
+ const entries = [];
3522
+ for (const key of changedKeys) {
3523
+ const sourceEntry = source.entries.get(key);
3524
+ const targetEntry = target.entries.get(key);
3525
+ if (sourceEntry === void 0 || targetEntry === void 0) {
3526
+ continue;
3527
+ }
3528
+ entries.push(checkEntryIntegrity(adapter, sourceEntry, targetEntry));
3529
+ }
3530
+ return entries;
3531
+ }
3532
+ async function keyIntegrity(input, deps = {}) {
3533
+ const config = input.config;
3534
+ const cwd = input.cwd ?? process.cwd();
3535
+ const fs = deps.fs ?? defaultFs;
3536
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
3537
+ const source = await readSource(config, cwd, fs, adapter);
3538
+ const lock = await readLockFile(lockFilePath(cwd), fs);
3539
+ return Promise.all(
3540
+ selectLocales(config, input.locales).map(async (locale) => {
3541
+ const target = await readTarget(cwd, config, adapter, fs, locale);
3542
+ const diffResult = diffResources(source.resource, target, {
3543
+ baseline: baselineFor(lock, locale)
3544
+ });
3545
+ const changedKeys = selectChangedKeys(diffResult.changed, input.keys);
3546
+ const entries = integrityEntriesFor(source.resource, target, adapter, changedKeys);
3547
+ return { locale, entries };
3548
+ })
3549
+ );
3550
+ }
3551
+
3552
+ // src/flow/key-value.ts
3553
+ async function keyValue(input, deps = {}) {
3554
+ const config = input.config;
3555
+ const cwd = input.cwd ?? process.cwd();
3556
+ const fs = deps.fs ?? defaultFs;
3557
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
3558
+ const [locale] = selectLocales(config, [input.locale]);
3559
+ if (locale === void 0) {
3560
+ throw new SdkError("UNKNOWN_LOCALE", `Locale "${input.locale}" could not be resolved.`);
3561
+ }
3562
+ const source = await readSource(config, cwd, fs, adapter);
3563
+ const sourceEntry = source.resource.entries.get(input.key);
3564
+ if (sourceEntry === void 0) {
3565
+ throw new SdkError(
3566
+ "UNKNOWN_KEY",
3567
+ `The key "${input.key}" was not found in the source resource.`
3568
+ );
3569
+ }
3570
+ const target = await readTarget(cwd, config, adapter, fs, locale);
3571
+ const targetEntry = target.entries.get(input.key);
3572
+ return {
3573
+ source: sourceEntry.value,
3574
+ ...targetEntry !== void 0 ? { target: targetEntry.value } : {}
3575
+ };
3576
+ }
3577
+
3578
+ // src/flow/locale-snapshot.ts
3579
+ async function readLocaleFileSnapshot(input, deps = {}) {
3580
+ const cwd = input.cwd ?? process.cwd();
3581
+ const fs = deps.fs ?? defaultFs;
3582
+ const adapter = selectAdapter(input.config.format, deps.adapterRegistry);
3583
+ const resource = await readTarget(cwd, input.config, adapter, fs, input.locale);
3584
+ const hashes = /* @__PURE__ */ new Map();
3585
+ for (const [key, entry] of resource.entries) {
3586
+ hashes.set(key, contentHash(entry));
3587
+ }
3588
+ return { locale: input.locale, hashes };
3589
+ }
3590
+ function diffLocaleSnapshots(previous, current) {
3591
+ let added = 0;
3592
+ let changed = 0;
3593
+ for (const [key, hash] of current.hashes) {
3594
+ const previousHash = previous.hashes.get(key);
3595
+ if (previousHash === void 0) {
3596
+ added += 1;
3597
+ } else if (previousHash !== hash) {
3598
+ changed += 1;
3599
+ }
3600
+ }
3601
+ let removed = 0;
3602
+ for (const key of previous.hashes.keys()) {
3603
+ if (!current.hashes.has(key)) {
3604
+ removed += 1;
3605
+ }
3606
+ }
3607
+ return { added, changed, removed };
3608
+ }
3609
+
3036
3610
  // src/flow/lock-state.ts
3037
3611
  function toLockLocaleState(locale, keyCount, diff2) {
3038
3612
  return {
@@ -3080,6 +3654,214 @@ function selectProvider(config, createProvider = buildProvider) {
3080
3654
  }
3081
3655
  }
3082
3656
 
3657
+ // src/flow/retranslate-entry.ts
3658
+ function buildSingleEntryRequest(config, locale, sourceEntry, adapter) {
3659
+ return {
3660
+ sourceLocale: config.sourceLocale,
3661
+ targetLocale: locale,
3662
+ entries: [sourceEntry],
3663
+ extractPlaceholders: adapter.extractPlaceholders,
3664
+ ...config.glossary !== void 0 ? { glossary: config.glossary } : {},
3665
+ ...config.tone !== void 0 ? { tone: config.tone } : {},
3666
+ ...adapter.comparePlaceholders !== void 0 ? { comparePlaceholders: adapter.comparePlaceholders } : {}
3667
+ };
3668
+ }
3669
+ async function retranslateEntry(input, deps = {}) {
3670
+ const config = input.config;
3671
+ const cwd = input.cwd ?? process.cwd();
3672
+ const fs = deps.fs ?? defaultFs;
3673
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
3674
+ const [locale] = selectLocales(config, [input.locale]);
3675
+ if (locale === void 0) {
3676
+ throw new SdkError("UNKNOWN_LOCALE", `Locale "${input.locale}" could not be resolved.`);
3677
+ }
3678
+ const source = await readSource(config, cwd, fs, adapter);
3679
+ const sourceEntry = source.resource.entries.get(input.key);
3680
+ if (sourceEntry === void 0) {
3681
+ throw new SdkError(
3682
+ "UNKNOWN_KEY",
3683
+ `The key "${input.key}" was not found in the source resource.`
3684
+ );
3685
+ }
3686
+ const provider = selectProvider(config.provider, deps.createProvider);
3687
+ return withLocaleWriteLock(cwd, locale, fs, async () => {
3688
+ const target = await readTarget(cwd, config, adapter, fs, locale);
3689
+ const result = await provider.translateBatch(
3690
+ buildSingleEntryRequest(config, locale, sourceEntry, adapter)
3691
+ );
3692
+ const value = result.values.get(input.key);
3693
+ if (value === void 0) {
3694
+ throw new ProviderError(
3695
+ "INVALID_RESPONSE",
3696
+ `The provider returned no translated value for key "${input.key}".`
3697
+ );
3698
+ }
3699
+ const gate = gateCandidateValue(sourceEntry, value, adapter);
3700
+ if (!gate.accepted) {
3701
+ return { accepted: false, reason: gate.reason, value };
3702
+ }
3703
+ const merged = new Map(target.entries);
3704
+ merged.set(input.key, { ...sourceEntry, value, namespace: target.namespace });
3705
+ const path = localeFilePath(cwd, config.files.pattern, locale);
3706
+ await adapter.write(
3707
+ { locale, namespace: target.namespace, format: config.format, entries: merged },
3708
+ path
3709
+ );
3710
+ await updateLockFileLocale(cwd, fs, locale, {
3711
+ mode: "merge",
3712
+ entries: { [input.key]: contentHash(sourceEntry) }
3713
+ });
3714
+ const reviewReasons = result.reviewFlags?.get(input.key)?.reasons ?? [];
3715
+ return { accepted: true, value, reviewReasons };
3716
+ });
3717
+ }
3718
+ var RUN_STATUS_DIR_NAME = ".verbatra-local";
3719
+ var RUN_STATUS_FILE_NAME = "run-status.json";
3720
+ var CURRENT_VERSION2 = 1;
3721
+ var MAX_RUN_STATUS_FILE_BYTES = 16 * 1024 * 1024;
3722
+ var reviewReasonCodeSchema = z.enum([
3723
+ "LENGTH_RATIO_OUTLIER",
3724
+ "EQUALS_SOURCE",
3725
+ "GLOSSARY_TERM_MISSED",
3726
+ "INTEGRITY_REORDERED",
3727
+ "PROVIDER_DEGRADED"
3728
+ ]);
3729
+ var needsReviewEntrySchema = z.object({
3730
+ key: z.string(),
3731
+ reasons: z.array(reviewReasonCodeSchema)
3732
+ });
3733
+ var usageSummarySchema = z.object({
3734
+ inputTokens: z.number().int().nonnegative(),
3735
+ outputTokens: z.number().int().nonnegative()
3736
+ });
3737
+ var runBudgetSchema = z.object({
3738
+ maxTokens: z.number().int().nonnegative(),
3739
+ behavior: z.enum(["warn", "stop"]),
3740
+ supported: z.boolean(),
3741
+ tokensUsed: z.number().int().nonnegative(),
3742
+ exceeded: z.boolean()
3743
+ });
3744
+ var runStatusLocaleSchema = z.object({
3745
+ locale: z.string(),
3746
+ status: z.enum(["succeeded", "failed"]),
3747
+ needsReview: z.array(needsReviewEntrySchema),
3748
+ usage: usageSummarySchema.optional()
3749
+ });
3750
+ var runStatusFileSchema = z.object({
3751
+ version: z.number().int().positive(),
3752
+ generatedAt: z.string(),
3753
+ usage: usageSummarySchema.optional(),
3754
+ budget: runBudgetSchema.optional(),
3755
+ locales: z.array(runStatusLocaleSchema)
3756
+ });
3757
+ function runStatusFilePath(cwd) {
3758
+ return resolve(cwd, RUN_STATUS_DIR_NAME, RUN_STATUS_FILE_NAME);
3759
+ }
3760
+ function toRunStatusLocale(locale) {
3761
+ return {
3762
+ locale: locale.locale,
3763
+ status: locale.status,
3764
+ needsReview: locale.needsReview,
3765
+ ...locale.usage !== void 0 ? { usage: locale.usage } : {}
3766
+ };
3767
+ }
3768
+ function buildRunStatusFile(summary, generatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
3769
+ return {
3770
+ version: CURRENT_VERSION2,
3771
+ generatedAt,
3772
+ ...summary.usage !== void 0 ? { usage: summary.usage } : {},
3773
+ ...summary.budget !== void 0 ? { budget: summary.budget } : {},
3774
+ locales: summary.locales.map(toRunStatusLocale)
3775
+ };
3776
+ }
3777
+ function fromParsed(data) {
3778
+ return {
3779
+ version: data.version,
3780
+ generatedAt: data.generatedAt,
3781
+ ...data.usage !== void 0 ? { usage: data.usage } : {},
3782
+ ...data.budget !== void 0 ? { budget: data.budget } : {},
3783
+ locales: data.locales.map((locale) => ({
3784
+ locale: locale.locale,
3785
+ status: locale.status,
3786
+ needsReview: locale.needsReview,
3787
+ ...locale.usage !== void 0 ? { usage: locale.usage } : {}
3788
+ }))
3789
+ };
3790
+ }
3791
+ async function readRunStatusFile(path, fs) {
3792
+ const read = await fs.readFileBounded(path, MAX_RUN_STATUS_FILE_BYTES);
3793
+ if (read.kind !== "ok") {
3794
+ return void 0;
3795
+ }
3796
+ let parsed;
3797
+ try {
3798
+ parsed = JSON.parse(read.content);
3799
+ } catch {
3800
+ return void 0;
3801
+ }
3802
+ const result = runStatusFileSchema.safeParse(parsed);
3803
+ if (!result.success || result.data.version !== CURRENT_VERSION2) {
3804
+ return void 0;
3805
+ }
3806
+ return fromParsed(result.data);
3807
+ }
3808
+ async function writeRunStatusFile(path, data, fs) {
3809
+ await mkdir(dirname(path), { recursive: true });
3810
+ await fs.writeFile(path, `${JSON.stringify(data, null, 2)}
3811
+ `);
3812
+ }
3813
+
3814
+ // src/flow/run-status.ts
3815
+ async function runStatus(input = {}, deps = {}) {
3816
+ const cwd = input.cwd ?? process.cwd();
3817
+ const fs = deps.fs ?? defaultFs;
3818
+ const file = await readRunStatusFile(runStatusFilePath(cwd), fs);
3819
+ if (file === void 0) {
3820
+ return { available: false };
3821
+ }
3822
+ return { available: true, ...file };
3823
+ }
3824
+
3825
+ // src/flow/budget.ts
3826
+ function createBudgetTracker(maxTokens, behavior) {
3827
+ return { maxTokens, behavior, tokensUsed: 0, usageSeen: false, exceeded: false, stopped: false };
3828
+ }
3829
+ function foldTrackerUsage(tracker, usage) {
3830
+ if (usage === void 0) {
3831
+ return;
3832
+ }
3833
+ tracker.usageSeen = true;
3834
+ tracker.tokensUsed += usage.inputTokens + usage.outputTokens;
3835
+ }
3836
+ function checkBudgetTrip(tracker) {
3837
+ if (tracker.maxTokens === void 0 || tracker.exceeded || tracker.tokensUsed < tracker.maxTokens) {
3838
+ return false;
3839
+ }
3840
+ tracker.exceeded = true;
3841
+ if (tracker.behavior === "stop") {
3842
+ tracker.stopped = true;
3843
+ }
3844
+ return true;
3845
+ }
3846
+ function toBudgetSummary(tracker) {
3847
+ if (tracker.maxTokens === void 0) {
3848
+ return void 0;
3849
+ }
3850
+ return {
3851
+ maxTokens: tracker.maxTokens,
3852
+ behavior: tracker.behavior,
3853
+ supported: tracker.usageSeen,
3854
+ tokensUsed: tracker.tokensUsed,
3855
+ exceeded: tracker.exceeded
3856
+ };
3857
+ }
3858
+ function budgetExceededNotice(tracker) {
3859
+ return {
3860
+ code: "BUDGET_TOKENS_EXCEEDED",
3861
+ message: `The run's cumulative token usage (${tracker.tokensUsed}) reached the configured budget of ${tracker.maxTokens} tokens (behavior: ${tracker.behavior}).`
3862
+ };
3863
+ }
3864
+
3083
3865
  // src/flow/locale-failure.ts
3084
3866
  function describeError(error) {
3085
3867
  if (error instanceof Error) {
@@ -3099,8 +3881,10 @@ function failureSummary(locale, error) {
3099
3881
  invalidIcuSource: [],
3100
3882
  integrityMismatches: [],
3101
3883
  providerFailures: [],
3884
+ budgetWithheld: [],
3102
3885
  generated: [],
3103
3886
  notices: [],
3887
+ needsReview: [],
3104
3888
  error: describeError(error)
3105
3889
  };
3106
3890
  }
@@ -3270,7 +4054,43 @@ function planPluralGeneration(source, targetLocale, format) {
3270
4054
  return { items };
3271
4055
  }
3272
4056
 
4057
+ // src/flow/usage.ts
4058
+ function createUsageAccumulator() {
4059
+ return { total: void 0 };
4060
+ }
4061
+ function foldUsage(accumulator, usage) {
4062
+ if (usage === void 0) {
4063
+ return;
4064
+ }
4065
+ const prior = accumulator.total ?? { inputTokens: 0, outputTokens: 0 };
4066
+ accumulator.total = {
4067
+ inputTokens: prior.inputTokens + usage.inputTokens,
4068
+ outputTokens: prior.outputTokens + usage.outputTokens
4069
+ };
4070
+ }
4071
+ function combineUsage(a, b) {
4072
+ if (a === void 0) {
4073
+ return b;
4074
+ }
4075
+ if (b === void 0) {
4076
+ return a;
4077
+ }
4078
+ return {
4079
+ inputTokens: a.inputTokens + b.inputTokens,
4080
+ outputTokens: a.outputTokens + b.outputTokens
4081
+ };
4082
+ }
4083
+
3273
4084
  // src/flow/plural-generation.ts
4085
+ var EMPTY_RESULT = {
4086
+ accepted: [],
4087
+ withheld: [],
4088
+ providerFailures: [],
4089
+ budgetWithheld: [],
4090
+ notices: [],
4091
+ usage: void 0,
4092
+ tripped: false
4093
+ };
3274
4094
  function generatedLockHash(governingEntries, category) {
3275
4095
  const governingHashes = governingEntries.map(contentHash).sort();
3276
4096
  return contentHash({
@@ -3284,7 +4104,6 @@ function syntheticEntry(item) {
3284
4104
  ...item.sourceEntry,
3285
4105
  key: item.targetKey,
3286
4106
  isPlural: true,
3287
- // The CLDR category travels as data context (the meaning field), never the instruction channel.
3288
4107
  meaning: `CLDR plural category "${item.category}"`
3289
4108
  };
3290
4109
  }
@@ -3309,23 +4128,45 @@ async function generatePluralForms(context) {
3309
4128
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
3310
4129
  const stale = staleItems(plan.items, context.baseline);
3311
4130
  if (stale.length === 0) {
3312
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
4131
+ return EMPTY_RESULT;
3313
4132
  }
3314
4133
  const accepted = [];
3315
4134
  const withheld = [];
3316
4135
  const providerFailures = [];
4136
+ const budgetWithheld = [];
3317
4137
  const notices = [];
4138
+ const usage = createUsageAccumulator();
4139
+ let tripped = false;
3318
4140
  for (const batch of chunk(stale, context.maxBatchSize)) {
3319
- const batchNotices = await runGenerationSubBatch(
4141
+ if (context.budget.stopped) {
4142
+ for (const item of batch) {
4143
+ budgetWithheld.push(item.targetKey);
4144
+ }
4145
+ continue;
4146
+ }
4147
+ const subResult = await runGenerationSubBatch(
3320
4148
  context,
3321
4149
  batch,
3322
4150
  accepted,
3323
4151
  withheld,
3324
4152
  providerFailures
3325
4153
  );
3326
- notices.push(...batchNotices);
4154
+ notices.push(...subResult.notices);
4155
+ foldUsage(usage, subResult.usage);
4156
+ foldTrackerUsage(context.budget, subResult.usage);
4157
+ if (checkBudgetTrip(context.budget)) {
4158
+ tripped = true;
4159
+ }
3327
4160
  }
3328
- return { accepted, withheld, providerFailures, notices };
4161
+ return {
4162
+ accepted,
4163
+ withheld,
4164
+ providerFailures,
4165
+ budgetWithheld,
4166
+ notices,
4167
+ usage: usage.total,
4168
+ tripped
4169
+ };
3329
4170
  }
3330
4171
  async function runGenerationSubBatch(context, batch, accepted, withheld, providerFailures) {
3331
4172
  let result;
@@ -3336,20 +4177,20 @@ async function runGenerationSubBatch(context, batch, accepted, withheld, provide
3336
4177
  for (const item of batch) {
3337
4178
  providerFailures.push(item.targetKey);
3338
4179
  }
3339
- return [subBatchFailedNotice(batch.length, error)];
4180
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3340
4181
  }
3341
4182
  for (const item of batch) {
3342
- foldGenerationItem(item, result, accepted, withheld, providerFailures);
4183
+ foldGenerationItem(item, result, context.adapter, accepted, withheld, providerFailures);
3343
4184
  }
3344
- return readNotices(result);
4185
+ return { notices: readNotices(result), usage: result.usage };
3345
4186
  }
3346
- function foldGenerationItem(item, result, accepted, withheld, providerFailures) {
4187
+ function foldGenerationItem(item, result, adapter, accepted, withheld, providerFailures) {
3347
4188
  const value = result.values.get(item.targetKey);
3348
4189
  if (value === void 0) {
3349
4190
  providerFailures.push(item.targetKey);
3350
4191
  return;
3351
4192
  }
3352
- if (result.integrity.get(item.targetKey)?.matches === true) {
4193
+ if (gateCandidateValue(item.sourceEntry, value, adapter).accepted) {
3353
4194
  accepted.push({
3354
4195
  targetKey: item.targetKey,
3355
4196
  entry: { ...syntheticEntry(item), value },
@@ -3361,16 +4202,6 @@ function foldGenerationItem(item, result, accepted, withheld, providerFailures)
3361
4202
  }
3362
4203
 
3363
4204
  // src/flow/locale-run.ts
3364
- function emptyResource(locale, format) {
3365
- return { locale, namespace: "", format, entries: /* @__PURE__ */ new Map() };
3366
- }
3367
- async function readTarget2(params) {
3368
- const path = localeFilePath(params.cwd, params.filesPattern, params.targetLocale);
3369
- if (!await params.fs.fileExists(path)) {
3370
- return emptyResource(params.targetLocale, params.format);
3371
- }
3372
- return (await params.adapter.read(path, params.targetLocale)).resource;
3373
- }
3374
4205
  function buildRequest3(params, entries) {
3375
4206
  return {
3376
4207
  sourceLocale: params.sourceLocale,
@@ -3383,7 +4214,14 @@ function buildRequest3(params, entries) {
3383
4214
  };
3384
4215
  }
3385
4216
  async function runLocale(params) {
3386
- const target = await readTarget2(params);
4217
+ const target = await readTargetResource({
4218
+ cwd: params.cwd,
4219
+ filesPattern: params.filesPattern,
4220
+ format: params.format,
4221
+ locale: params.targetLocale,
4222
+ adapter: params.adapter,
4223
+ fs: params.fs
4224
+ });
3387
4225
  const diff2 = diffResources(params.source, target, { baseline: params.baseline });
3388
4226
  const orphaned = params.generatePlurals ? diff2.orphaned.filter((key) => !isGeneratedPluralKey(key, sourcePluralBaseKeys(params.source))) : diff2.orphaned;
3389
4227
  const pruned = params.prune ? orphaned : [];
@@ -3409,6 +4247,7 @@ async function runLocale(params) {
3409
4247
  generated: [],
3410
4248
  integrityMismatches: [],
3411
4249
  providerFailures: [],
4250
+ budgetWithheld: [],
3412
4251
  pruned,
3413
4252
  notices: sdkNotices
3414
4253
  }),
@@ -3416,23 +4255,31 @@ async function runLocale(params) {
3416
4255
  };
3417
4256
  }
3418
4257
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
4258
+ const startedStopped = params.budget.stopped;
3419
4259
  const accepted = /* @__PURE__ */ new Map();
3420
4260
  const integrityMismatches = [];
3421
4261
  const providerFailures = [];
3422
- const subBatchNotices = await translateAndCheck(
4262
+ const budgetWithheld = [];
4263
+ const reviewFlags = /* @__PURE__ */ new Map();
4264
+ const translation = await translateAndCheck(
3423
4265
  provider,
3424
4266
  params,
3425
4267
  entries,
3426
4268
  accepted,
3427
4269
  integrityMismatches,
3428
- providerFailures
4270
+ providerFailures,
4271
+ budgetWithheld,
4272
+ reviewFlags
3429
4273
  );
3430
4274
  const merged = new Map(target.entries);
3431
4275
  for (const key of pruned) {
3432
4276
  merged.delete(key);
3433
4277
  }
3434
- for (const [key, { value, source }] of accepted) {
3435
- merged.set(key, { ...source, value, namespace: target.namespace });
4278
+ for (const key of params.source.entries.keys()) {
4279
+ const hit = accepted.get(key);
4280
+ if (hit !== void 0) {
4281
+ merged.set(key, { ...hit.source, value: hit.value, namespace: target.namespace });
4282
+ }
3436
4283
  }
3437
4284
  const generation = await runGeneration(params, provider);
3438
4285
  for (const form of generation.accepted) {
@@ -3451,16 +4298,19 @@ async function runLocale(params) {
3451
4298
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
3452
4299
  const notices = [
3453
4300
  ...pluralNotices,
3454
- ...subBatchNotices,
3455
- ...generation.notices
4301
+ ...translation.notices,
4302
+ ...generation.notices,
4303
+ ...budgetLocaleNotices(params.budget, startedStopped, translation.tripped, generation.tripped)
3456
4304
  ];
3457
4305
  const withheld = /* @__PURE__ */ new Set([
3458
4306
  ...integrityMismatches,
3459
4307
  ...providerFailures,
3460
4308
  ...invalidIcuSource,
3461
4309
  ...generation.withheld,
3462
- ...generation.providerFailures
4310
+ ...generation.providerFailures,
4311
+ ...budgetWithheld
3463
4312
  ]);
4313
+ const localeUsage = combineUsage(translation.usage, generation.usage);
3464
4314
  return {
3465
4315
  summary: baseSummary({
3466
4316
  locale: params.targetLocale,
@@ -3469,19 +4319,29 @@ async function runLocale(params) {
3469
4319
  invalidIcuSource,
3470
4320
  translated: [...accepted.keys()],
3471
4321
  generated: generation.accepted.map((form) => form.targetKey).sort(),
3472
- // Withheld generated forms surface alongside withheld translations: both failed integrity.
3473
4322
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
3474
- // A generation sub-batch whose provider call itself threw is a provider failure, never integrity.
3475
4323
  providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
4324
+ budgetWithheld: [...budgetWithheld, ...generation.budgetWithheld].sort(),
3476
4325
  pruned,
3477
- notices
4326
+ notices,
4327
+ needsReview: needsReviewFor(accepted.keys(), reviewFlags),
4328
+ ...localeUsage !== void 0 ? { usage: localeUsage } : {}
3478
4329
  }),
3479
4330
  lockEntries: computeLockEntries(params, merged, withheld, generation.accepted)
3480
4331
  };
3481
4332
  }
4333
+ var NO_GENERATION_RESULT = {
4334
+ accepted: [],
4335
+ withheld: [],
4336
+ providerFailures: [],
4337
+ budgetWithheld: [],
4338
+ notices: [],
4339
+ usage: void 0,
4340
+ tripped: false
4341
+ };
3482
4342
  async function runGeneration(params, provider) {
3483
4343
  if (!params.generatePlurals || provider.kind !== "llm") {
3484
- return { accepted: [], withheld: [], providerFailures: [], notices: [] };
4344
+ return NO_GENERATION_RESULT;
3485
4345
  }
3486
4346
  return generatePluralForms({
3487
4347
  source: params.source,
@@ -3493,9 +4353,13 @@ async function runGeneration(params, provider) {
3493
4353
  glossary: params.glossary,
3494
4354
  tone: params.tone,
3495
4355
  baseline: params.baseline,
3496
- maxBatchSize: params.maxBatchSize
4356
+ maxBatchSize: params.maxBatchSize,
4357
+ budget: params.budget
3497
4358
  });
3498
4359
  }
4360
+ function budgetLocaleNotices(budget, startedStopped, mainTripped, generationTripped) {
4361
+ return startedStopped || mainTripped || generationTripped ? [budgetExceededNotice(budget)] : [];
4362
+ }
3499
4363
  function pluralNoticeFor(params, merged) {
3500
4364
  if (params.format !== "i18next-json") {
3501
4365
  return [];
@@ -3516,26 +4380,53 @@ function baseSummary(parts) {
3516
4380
  invalidIcuSource: parts.invalidIcuSource,
3517
4381
  integrityMismatches: parts.integrityMismatches,
3518
4382
  providerFailures: parts.providerFailures,
4383
+ budgetWithheld: parts.budgetWithheld,
3519
4384
  generated: parts.generated,
3520
- notices: parts.notices
4385
+ notices: parts.notices,
4386
+ needsReview: parts.needsReview ?? [],
4387
+ ...parts.usage !== void 0 ? { usage: parts.usage } : {}
3521
4388
  };
3522
4389
  }
3523
- async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures) {
4390
+ function needsReviewFor(acceptedKeys, reviewFlags) {
4391
+ const entries = [];
4392
+ for (const key of acceptedKeys) {
4393
+ const flag = reviewFlags.get(key);
4394
+ if (flag !== void 0) {
4395
+ entries.push({ key, reasons: flag.reasons });
4396
+ }
4397
+ }
4398
+ return entries.sort((a, b) => a.key < b.key ? -1 : 1);
4399
+ }
4400
+ async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures, budgetWithheld, reviewFlags) {
3524
4401
  const notices = [];
4402
+ const usage = createUsageAccumulator();
4403
+ let tripped = false;
3525
4404
  for (const batch of chunk(entries, params.maxBatchSize)) {
3526
- const subNotices = await runSubBatch(
4405
+ if (params.budget.stopped) {
4406
+ for (const entry of batch) {
4407
+ budgetWithheld.push(entry.key);
4408
+ }
4409
+ continue;
4410
+ }
4411
+ const subResult = await runSubBatch(
3527
4412
  provider,
3528
4413
  params,
3529
4414
  batch,
3530
4415
  accepted,
3531
4416
  integrityMismatches,
3532
- providerFailures
4417
+ providerFailures,
4418
+ reviewFlags
3533
4419
  );
3534
- notices.push(...subNotices);
4420
+ notices.push(...subResult.notices);
4421
+ foldUsage(usage, subResult.usage);
4422
+ foldTrackerUsage(params.budget, subResult.usage);
4423
+ if (checkBudgetTrip(params.budget)) {
4424
+ tripped = true;
4425
+ }
3535
4426
  }
3536
- return notices;
4427
+ return { notices, tripped, usage: usage.total };
3537
4428
  }
3538
- async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures) {
4429
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures, reviewFlags) {
3539
4430
  let result;
3540
4431
  try {
3541
4432
  result = await provider.translateBatch(buildRequest3(params, batch));
@@ -3543,20 +4434,25 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
3543
4434
  for (const entry of batch) {
3544
4435
  providerFailures.push(entry.key);
3545
4436
  }
3546
- return [subBatchFailedNotice(batch.length, error)];
4437
+ return { notices: [subBatchFailedNotice(batch.length, error)], usage: void 0 };
3547
4438
  }
3548
4439
  for (const entry of batch) {
3549
- foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures);
4440
+ foldEntryResult(entry, result, params.adapter, accepted, integrityMismatches, providerFailures);
4441
+ }
4442
+ if (result.reviewFlags !== void 0) {
4443
+ for (const [key, flag] of result.reviewFlags) {
4444
+ reviewFlags.set(key, flag);
4445
+ }
3550
4446
  }
3551
- return readNotices(result);
4447
+ return { notices: readNotices(result), usage: result.usage };
3552
4448
  }
3553
- function foldEntryResult(entry, result, accepted, integrityMismatches, providerFailures) {
4449
+ function foldEntryResult(entry, result, adapter, accepted, integrityMismatches, providerFailures) {
3554
4450
  const value = result.values.get(entry.key);
3555
4451
  if (value === void 0) {
3556
4452
  providerFailures.push(entry.key);
3557
4453
  return;
3558
4454
  }
3559
- if (result.integrity.get(entry.key)?.matches === true) {
4455
+ if (gateCandidateValue(entry, value, adapter).accepted) {
3560
4456
  accepted.set(entry.key, { value, source: entry });
3561
4457
  } else {
3562
4458
  integrityMismatches.push(entry.key);
@@ -3598,6 +4494,79 @@ function carryGeneratedLock(lockEntries, baseline, key, sourceBaseKeys) {
3598
4494
  }
3599
4495
 
3600
4496
  // src/flow/translate-project.ts
4497
+ async function recordRunStatus(cwd, dryRun, summary, fs) {
4498
+ if (dryRun) {
4499
+ return;
4500
+ }
4501
+ try {
4502
+ await writeRunStatusFile(runStatusFilePath(cwd), buildRunStatusFile(summary), fs);
4503
+ } catch {
4504
+ }
4505
+ }
4506
+ function buildLocaleRunParams(context, targetLocale, baseline) {
4507
+ return {
4508
+ source: context.source.resource,
4509
+ sourceInvalidIcuKeys: context.source.invalidIcuKeys,
4510
+ baseline,
4511
+ adapter: context.adapter,
4512
+ provider: context.provider,
4513
+ cwd: context.cwd,
4514
+ filesPattern: context.config.files.pattern,
4515
+ sourceLocale: context.config.sourceLocale,
4516
+ targetLocale,
4517
+ format: context.config.format,
4518
+ glossary: context.config.glossary,
4519
+ tone: context.config.tone,
4520
+ prune: context.prune,
4521
+ generatePlurals: context.generatePlurals,
4522
+ maxBatchSize: context.maxBatchSize,
4523
+ fs: context.fs,
4524
+ budget: context.budget
4525
+ };
4526
+ }
4527
+ async function runDryLocale(context, targetLocale, lock) {
4528
+ const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
4529
+ return (await runLocale(params)).summary;
4530
+ }
4531
+ async function runLiveLocale(context, targetLocale) {
4532
+ return withLocaleWriteLock(context.cwd, targetLocale, context.fs, async () => {
4533
+ const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4534
+ const params = buildLocaleRunParams(context, targetLocale, baselineFor(lock, targetLocale));
4535
+ const result = await runLocale(params);
4536
+ await updateLockFileLocale(context.cwd, context.fs, targetLocale, {
4537
+ mode: "replace",
4538
+ entries: result.lockEntries
4539
+ });
4540
+ return result.summary;
4541
+ });
4542
+ }
4543
+ async function runOneLocale(targetLocale, run) {
4544
+ try {
4545
+ return await run();
4546
+ } catch (error) {
4547
+ if (error instanceof SdkError && error.code === "LOCK_FILE_INVALID") {
4548
+ throw error;
4549
+ }
4550
+ return failureSummary(targetLocale, error);
4551
+ }
4552
+ }
4553
+ async function runAllLocalesDry(context, targetLocales) {
4554
+ const lock = await readLockFile(lockFilePath(context.cwd), context.fs);
4555
+ const summaries = [];
4556
+ for (const targetLocale of targetLocales) {
4557
+ summaries.push(
4558
+ await runOneLocale(targetLocale, () => runDryLocale(context, targetLocale, lock))
4559
+ );
4560
+ }
4561
+ return summaries;
4562
+ }
4563
+ async function runAllLocalesLive(context, targetLocales) {
4564
+ const summaries = [];
4565
+ for (const targetLocale of targetLocales) {
4566
+ summaries.push(await runOneLocale(targetLocale, () => runLiveLocale(context, targetLocale)));
4567
+ }
4568
+ return summaries;
4569
+ }
3601
4570
  async function translate2(input, deps = {}) {
3602
4571
  const config = input.config;
3603
4572
  const cwd = input.cwd ?? process.cwd();
@@ -3606,44 +4575,42 @@ async function translate2(input, deps = {}) {
3606
4575
  const generatePlurals = input.generatePlurals ?? config.generatePlurals ?? false;
3607
4576
  const maxBatchSize = config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
3608
4577
  const fs = deps.fs ?? defaultFs;
4578
+ const budget = createBudgetTracker(
4579
+ config.maxTokens,
4580
+ config.budgetBehavior ?? DEFAULT_BUDGET_BEHAVIOR
4581
+ );
3609
4582
  const adapter = selectAdapter(config.format, deps.adapterRegistry);
3610
4583
  const provider = dryRun ? void 0 : selectProvider(config.provider, deps.createProvider);
3611
4584
  const source = await readSource(config, cwd, fs, adapter);
3612
- const lockPath = lockFilePath(cwd);
3613
- let lock = await readLockFile(lockPath, fs);
3614
- const summaries = [];
3615
- for (const targetLocale of config.targetLocales) {
3616
- try {
3617
- const params = {
3618
- source: source.resource,
3619
- sourceInvalidIcuKeys: source.invalidIcuKeys,
3620
- baseline: baselineFor(lock, targetLocale),
3621
- adapter,
3622
- provider,
3623
- cwd,
3624
- filesPattern: config.files.pattern,
3625
- sourceLocale: config.sourceLocale,
3626
- targetLocale,
3627
- format: config.format,
3628
- glossary: config.glossary,
3629
- tone: config.tone,
3630
- prune,
3631
- generatePlurals,
3632
- maxBatchSize,
3633
- fs
3634
- };
3635
- const { summary, lockEntries } = await runLocale(params);
3636
- if (!dryRun) {
3637
- lock = updateLockLocale(lock, targetLocale, lockEntries);
3638
- await writeLockFile(lockPath, lock, fs);
3639
- }
3640
- summaries.push(summary);
3641
- } catch (error) {
3642
- summaries.push(failureSummary(targetLocale, error));
3643
- }
3644
- }
4585
+ const context = {
4586
+ source,
4587
+ adapter,
4588
+ provider,
4589
+ cwd,
4590
+ config,
4591
+ prune,
4592
+ generatePlurals,
4593
+ maxBatchSize,
4594
+ fs,
4595
+ budget
4596
+ };
4597
+ const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales) : await runAllLocalesLive(context, config.targetLocales);
3645
4598
  const { succeeded, failed } = partition(summaries);
3646
- return { dryRun, locales: summaries, succeeded, failed };
4599
+ const usage = summaries.reduce(
4600
+ (total, summary2) => combineUsage(total, summary2.usage),
4601
+ void 0
4602
+ );
4603
+ const budgetSummary = toBudgetSummary(budget);
4604
+ const summary = {
4605
+ dryRun,
4606
+ locales: summaries,
4607
+ succeeded,
4608
+ failed,
4609
+ ...usage !== void 0 ? { usage } : {},
4610
+ ...budgetSummary !== void 0 ? { budget: budgetSummary } : {}
4611
+ };
4612
+ await recordRunStatus(cwd, dryRun, summary, fs);
4613
+ return summary;
3647
4614
  }
3648
4615
  var ExchangeError = class extends Error {
3649
4616
  code;
@@ -3684,7 +4651,9 @@ var COLUMN = {
3684
4651
  status: 4,
3685
4652
  translation: 5,
3686
4653
  sourceHash: 6,
3687
- context: 7
4654
+ context: 7,
4655
+ reviewStatus: 8,
4656
+ reviewReasons: 9
3688
4657
  };
3689
4658
  var HEADERS = [
3690
4659
  "Key",
@@ -3693,7 +4662,9 @@ var HEADERS = [
3693
4662
  "Status",
3694
4663
  "Translation",
3695
4664
  "Source hash",
3696
- "Context"
4665
+ "Context",
4666
+ "Review status",
4667
+ "Review reasons"
3697
4668
  ];
3698
4669
  var HEADER_ROW = 1;
3699
4670
  var INSTRUCTIONS_SHEET_NAME = "Instructions";
@@ -3713,7 +4684,9 @@ var COLUMN_WIDTHS = {
3713
4684
  [COLUMN.current]: 50,
3714
4685
  [COLUMN.status]: 12,
3715
4686
  [COLUMN.translation]: 50,
3716
- [COLUMN.context]: 50
4687
+ [COLUMN.context]: 50,
4688
+ [COLUMN.reviewStatus]: 12,
4689
+ [COLUMN.reviewReasons]: 40
3717
4690
  };
3718
4691
  var TEXT_NUMBER_FORMAT = "@";
3719
4692
  function styleHeader(sheet) {
@@ -3745,7 +4718,9 @@ function writeRow(sheet, sheetRow) {
3745
4718
  translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
3746
4719
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
3747
4720
  row.getCell(COLUMN.context).value = sheetRow.context;
3748
- for (let column = COLUMN.key; column <= COLUMN.context; column += 1) {
4721
+ row.getCell(COLUMN.reviewStatus).value = sheetRow.reviewStatus;
4722
+ row.getCell(COLUMN.reviewReasons).value = sheetRow.reviewReasons;
4723
+ for (let column = COLUMN.key; column <= COLUMN.reviewReasons; column += 1) {
3749
4724
  const cell = row.getCell(column);
3750
4725
  cell.protection = { locked: column !== COLUMN.translation };
3751
4726
  if (column !== COLUMN.translation) {
@@ -3920,7 +4895,9 @@ var rowSchema = z.object({
3920
4895
  status: z.enum(["new", "changed", "unchanged"]),
3921
4896
  sourceHash: z.string(),
3922
4897
  translation: z.string(),
3923
- context: z.string()
4898
+ context: z.string(),
4899
+ reviewStatus: z.enum(["ok", "review"]).catch("ok"),
4900
+ reviewReasons: z.string().catch("")
3924
4901
  });
3925
4902
  function cellString(cell) {
3926
4903
  const value = cell.value;
@@ -3954,9 +4931,9 @@ function parseRow(sheet, row) {
3954
4931
  status: cellString(row.getCell(COLUMN.status)),
3955
4932
  sourceHash: cellString(row.getCell(COLUMN.sourceHash)),
3956
4933
  translation: cellString(row.getCell(COLUMN.translation)),
3957
- // A workbook built before the Context column existed has no cell here; getCell auto-vivifies an
3958
- // empty one, so cellString yields "" and the row still validates, keeping import backward-compatible.
3959
- context: cellString(row.getCell(COLUMN.context))
4934
+ context: cellString(row.getCell(COLUMN.context)),
4935
+ reviewStatus: cellString(row.getCell(COLUMN.reviewStatus)),
4936
+ reviewReasons: cellString(row.getCell(COLUMN.reviewReasons))
3960
4937
  };
3961
4938
  const result = rowSchema.safeParse(candidate);
3962
4939
  if (!result.success) {
@@ -4023,14 +5000,34 @@ async function readWorkbook(bytes, options = {}) {
4023
5000
 
4024
5001
  // src/flow/workbook/export-workbook.ts
4025
5002
  var DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
4026
- async function readTarget3(cwd, config, adapter, fs, locale) {
4027
- const path = localeFilePath(cwd, config.files.pattern, locale);
4028
- if (!await fs.fileExists(path)) {
4029
- return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
5003
+ function reasonLabel(reason) {
5004
+ return reason.toLowerCase().replace(/_/g, "-");
5005
+ }
5006
+ function reviewColumns(flag) {
5007
+ if (flag === void 0) {
5008
+ return { reviewStatus: "ok", reviewReasons: "" };
4030
5009
  }
4031
- return (await adapter.read(path, locale)).resource;
5010
+ return { reviewStatus: "review", reviewReasons: flag.reasons.map(reasonLabel).join(", ") };
4032
5011
  }
4033
- function buildRows(source, target, baseline, includeUnchanged) {
5012
+ function computeRowReview(adapter, sourceValue, currentTarget, sourceLocale, targetLocale, glossary) {
5013
+ if (currentTarget === "") {
5014
+ return { reviewStatus: "ok", reviewReasons: "" };
5015
+ }
5016
+ const integrity = adapter.comparePlaceholders?.(sourceValue, currentTarget) ?? checkPlaceholders(
5017
+ adapter.extractPlaceholders(sourceValue),
5018
+ adapter.extractPlaceholders(currentTarget)
5019
+ );
5020
+ const flag = computeReviewFlags({
5021
+ sourceValue,
5022
+ translatedValue: currentTarget,
5023
+ sourceLocale,
5024
+ targetLocale,
5025
+ integrity,
5026
+ glossary
5027
+ });
5028
+ return reviewColumns(flag);
5029
+ }
5030
+ function buildRows(source, target, baseline, includeUnchanged, adapter, glossary) {
4034
5031
  const diff2 = diffResources(source, target, { baseline });
4035
5032
  const rows = [];
4036
5033
  const add = (keys, status) => {
@@ -4039,14 +5036,23 @@ function buildRows(source, target, baseline, includeUnchanged) {
4039
5036
  if (sourceEntry === void 0) {
4040
5037
  continue;
4041
5038
  }
5039
+ const currentTarget = target.entries.get(key)?.value ?? "";
4042
5040
  rows.push({
4043
5041
  key,
4044
5042
  source: sourceEntry.value,
4045
- currentTarget: target.entries.get(key)?.value ?? "",
5043
+ currentTarget,
4046
5044
  status,
4047
5045
  sourceHash: contentHash(sourceEntry),
4048
5046
  translation: "",
4049
- context: sourceEntry.description ?? ""
5047
+ context: sourceEntry.description ?? "",
5048
+ ...computeRowReview(
5049
+ adapter,
5050
+ sourceEntry.value,
5051
+ currentTarget,
5052
+ source.locale,
5053
+ target.locale,
5054
+ glossary
5055
+ )
4050
5056
  });
4051
5057
  }
4052
5058
  };
@@ -4067,12 +5073,14 @@ async function exportWorkbook(input, deps = {}) {
4067
5073
  const locales = selectLocales(config, input.locales);
4068
5074
  const sheets = await Promise.all(
4069
5075
  locales.map(async (locale) => {
4070
- const target = await readTarget3(cwd, config, adapter, fs, locale);
5076
+ const target = await readTarget(cwd, config, adapter, fs, locale);
4071
5077
  const rows = buildRows(
4072
5078
  source.resource,
4073
5079
  target,
4074
5080
  baselineFor(lock, locale),
4075
- input.includeUnchanged ?? false
5081
+ input.includeUnchanged ?? false,
5082
+ adapter,
5083
+ config.glossary
4076
5084
  );
4077
5085
  return { locale, rows };
4078
5086
  })
@@ -4103,14 +5111,8 @@ function judge(row, sourceEntry, adapter) {
4103
5111
  if (contentHash(sourceEntry) !== row.sourceHash) {
4104
5112
  return "drift";
4105
5113
  }
4106
- const integrity = adapter.comparePlaceholders !== void 0 ? adapter.comparePlaceholders(sourceEntry.value, row.translation) : checkPlaceholders(sourceEntry.placeholders, adapter.extractPlaceholders(row.translation));
4107
- if (!integrity.matches) {
4108
- return "placeholder";
4109
- }
4110
- if (!adapter.validateMessage(row.translation)) {
4111
- return "icu";
4112
- }
4113
- return void 0;
5114
+ const gate = gateCandidateValue(sourceEntry, row.translation, adapter);
5115
+ return gate.accepted ? void 0 : gate.reason;
4114
5116
  }
4115
5117
  function trackBlankDrift(row, params, buckets) {
4116
5118
  const sourceEntry = params.source.entries.get(row.key);
@@ -4167,15 +5169,14 @@ function importLocale(params) {
4167
5169
  translated: [...buckets.accepted.keys()].sort(),
4168
5170
  unchanged: diff2.unchanged,
4169
5171
  orphaned: diff2.orphaned,
4170
- // Import never prunes: orphans are reported but never removed here (pruning is a translate-flow concern).
4171
5172
  pruned: [],
4172
5173
  invalidIcuSource,
4173
5174
  integrityMismatches: [...buckets.mismatches].sort(),
4174
- // A workbook import never calls a provider, so a provider-call failure cannot occur here.
4175
5175
  providerFailures: [],
4176
- // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
5176
+ budgetWithheld: [],
4177
5177
  generated: [],
4178
- notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : []
5178
+ notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : [],
5179
+ needsReview: []
4179
5180
  };
4180
5181
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
4181
5182
  }
@@ -4195,13 +5196,6 @@ async function readWorkbookBytes(path, fs) {
4195
5196
  }
4196
5197
  return read.bytes;
4197
5198
  }
4198
- async function readTarget4(cwd, config, adapter, fs, locale) {
4199
- const path = localeFilePath(cwd, config.files.pattern, locale);
4200
- if (!await fs.fileExists(path)) {
4201
- return { locale, namespace: "", format: config.format, entries: /* @__PURE__ */ new Map() };
4202
- }
4203
- return (await adapter.read(path, locale)).resource;
4204
- }
4205
5199
  function mergeAccepted(target, accepted) {
4206
5200
  const merged = new Map(target.entries);
4207
5201
  for (const [key, { value, source }] of accepted) {
@@ -4232,7 +5226,7 @@ async function runSheet(ctx, sheet, lock) {
4232
5226
  `The workbook has a sheet for locale "${sheet.locale}", which is not a configured target locale.`
4233
5227
  );
4234
5228
  }
4235
- const target = await readTarget4(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
5229
+ const target = await readTarget(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
4236
5230
  const baseline = baselineFor(lock, sheet.locale);
4237
5231
  const { summary, accepted } = importLocale({
4238
5232
  sheet,
@@ -4275,8 +5269,7 @@ async function importWorkbook(input, deps = {}) {
4275
5269
  } catch (error) {
4276
5270
  throw new SdkError("SOURCE_INVALID", error.message);
4277
5271
  }
4278
- const lockPath = lockFilePath(cwd);
4279
- let lock = await readLockFile(lockPath, fs);
5272
+ const lock = await readLockFile(lockFilePath(cwd), fs);
4280
5273
  const ctx = {
4281
5274
  config,
4282
5275
  cwd,
@@ -4289,10 +5282,18 @@ async function importWorkbook(input, deps = {}) {
4289
5282
  const summaries = [];
4290
5283
  for (const sheet of data.sheets) {
4291
5284
  try {
4292
- const { summary, lockEntries } = await runSheet(ctx, sheet, lock);
4293
- if (!dryRun) {
4294
- lock = updateLockLocale(lock, sheet.locale, lockEntries);
4295
- await writeLockFile(lockPath, lock, fs);
5285
+ let summary;
5286
+ if (dryRun) {
5287
+ summary = (await runSheet(ctx, sheet, lock)).summary;
5288
+ } else {
5289
+ summary = await withLocaleWriteLock(cwd, sheet.locale, fs, async () => {
5290
+ const result = await runSheet(ctx, sheet, lock);
5291
+ await updateLockFileLocale(cwd, fs, sheet.locale, {
5292
+ mode: "replace",
5293
+ entries: result.lockEntries
5294
+ });
5295
+ return result.summary;
5296
+ });
4296
5297
  }
4297
5298
  summaries.push(summary);
4298
5299
  } catch (error) {
@@ -4425,6 +5426,6 @@ async function watch(input, deps = {}) {
4425
5426
  return { stop };
4426
5427
  }
4427
5428
 
4428
- export { DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, exportWorkbook, importWorkbook, loadConfig, loadConfigWithMeta, loadLockFile, lockState, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
5429
+ export { DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
4429
5430
  //# sourceMappingURL=index.js.map
4430
5431
  //# sourceMappingURL=index.js.map