@verbatra/sdk 0.4.3 → 0.5.0-next.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
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from 'fs';
2
- import { resolve, join, dirname, basename, extname } from 'path';
2
+ import { dirname, resolve, join, basename, extname } from 'path';
3
3
  import { cosmiconfig } from 'cosmiconfig';
4
4
  import { TypeScriptLoader } from 'cosmiconfig-typescript-loader';
5
+ import { randomUUID } from 'crypto';
6
+ import { access, writeFile, rename, rm, open } from 'fs/promises';
5
7
  import { z } from 'zod';
6
8
  import Anthropic from '@anthropic-ai/sdk';
7
9
  import * as deepl from 'deepl-node';
@@ -9,8 +11,6 @@ import { createRequire } from 'module';
9
11
  import log from 'loglevel';
10
12
  import { GoogleGenAI } from '@google/genai';
11
13
  import OpenAI from 'openai';
12
- import { randomUUID } from 'crypto';
13
- import { access, writeFile, rename, rm, open } from 'fs/promises';
14
14
  import { parse as parse$1, TYPE } from '@formatjs/icu-messageformat-parser';
15
15
  import { XMLSerializer, DOMParser } from '@xmldom/xmldom';
16
16
  import { stringify, parse } from 'yaml';
@@ -38,6 +38,155 @@ var SdkError = class extends Error {
38
38
  this.code = code;
39
39
  }
40
40
  };
41
+ async function readBoundedUtf8(handle, size) {
42
+ const buffer = Buffer.allocUnsafe(size);
43
+ let offset = 0;
44
+ while (offset < size) {
45
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
46
+ if (bytesRead === 0) {
47
+ break;
48
+ }
49
+ offset += bytesRead;
50
+ }
51
+ return buffer.toString("utf8", 0, offset);
52
+ }
53
+ async function readBounded(path, maxBytes) {
54
+ let handle;
55
+ try {
56
+ handle = await open(path, "r");
57
+ } catch {
58
+ return { kind: "missing" };
59
+ }
60
+ try {
61
+ const info = await handle.stat();
62
+ if (!info.isFile()) {
63
+ return { kind: "missing" };
64
+ }
65
+ if (info.size > maxBytes) {
66
+ return { kind: "too-large" };
67
+ }
68
+ return { kind: "ok", content: await readBoundedUtf8(handle, info.size) };
69
+ } finally {
70
+ await handle.close();
71
+ }
72
+ }
73
+ async function readBoundedBytesInto(handle, size) {
74
+ const buffer = Buffer.allocUnsafeSlow(size);
75
+ let offset = 0;
76
+ while (offset < size) {
77
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
78
+ if (bytesRead === 0) {
79
+ break;
80
+ }
81
+ offset += bytesRead;
82
+ }
83
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, offset);
84
+ }
85
+ async function readBoundedBytes(path, maxBytes) {
86
+ let handle;
87
+ try {
88
+ handle = await open(path, "r");
89
+ } catch {
90
+ return { kind: "missing" };
91
+ }
92
+ try {
93
+ const info = await handle.stat();
94
+ if (!info.isFile()) {
95
+ return { kind: "missing" };
96
+ }
97
+ if (info.size > maxBytes) {
98
+ return { kind: "too-large" };
99
+ }
100
+ return { kind: "ok", bytes: await readBoundedBytesInto(handle, info.size) };
101
+ } finally {
102
+ await handle.close();
103
+ }
104
+ }
105
+ function tempFileName(path) {
106
+ return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
107
+ }
108
+ async function atomicWrite(path, data) {
109
+ const tmp = tempFileName(path);
110
+ await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
111
+ try {
112
+ await rename(tmp, path);
113
+ } catch (error) {
114
+ await rm(tmp, { force: true });
115
+ throw error;
116
+ }
117
+ }
118
+ var defaultFs = {
119
+ async fileExists(path) {
120
+ try {
121
+ await access(path);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ },
127
+ readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
128
+ readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
129
+ writeFile: (path, data) => atomicWrite(path, data),
130
+ writeBytes: (path, data) => atomicWrite(path, data)
131
+ };
132
+ var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
133
+ var glossaryRecordSchema = z.record(z.string(), z.string());
134
+ var BOM = "\uFEFF";
135
+ var REPLACEMENT_CHARACTER = "\uFFFD";
136
+ var NUL = "\0";
137
+ function stripBom(content) {
138
+ return content.startsWith(BOM) ? content.slice(BOM.length) : content;
139
+ }
140
+ function looksLikeInvalidEncoding(content) {
141
+ return content.startsWith(REPLACEMENT_CHARACTER) || content.includes(NUL);
142
+ }
143
+ async function readGlossaryFile(resolvedPath, fs) {
144
+ const read = await fs.readFileBounded(resolvedPath, MAX_GLOSSARY_FILE_BYTES);
145
+ if (read.kind === "missing") {
146
+ throw new SdkError(
147
+ "CONFIG_INVALID",
148
+ `The glossary file at ${resolvedPath} was not found or could not be read.`
149
+ );
150
+ }
151
+ if (read.kind === "too-large") {
152
+ throw new SdkError(
153
+ "CONFIG_INVALID",
154
+ `The glossary file at ${resolvedPath} exceeds the maximum allowed size of ${MAX_GLOSSARY_FILE_BYTES} bytes.`
155
+ );
156
+ }
157
+ const content = stripBom(read.content);
158
+ if (looksLikeInvalidEncoding(content)) {
159
+ throw new SdkError(
160
+ "CONFIG_INVALID",
161
+ `The glossary file at ${resolvedPath} must be UTF-8 encoded.`
162
+ );
163
+ }
164
+ let parsed;
165
+ try {
166
+ parsed = JSON.parse(content);
167
+ } catch {
168
+ throw new SdkError("CONFIG_INVALID", `The glossary file at ${resolvedPath} is not valid JSON.`);
169
+ }
170
+ const result = glossaryRecordSchema.safeParse(parsed);
171
+ if (!result.success) {
172
+ throw new SdkError(
173
+ "CONFIG_INVALID",
174
+ `The glossary file at ${resolvedPath} must contain a flat object of string keys to string values.`
175
+ );
176
+ }
177
+ return result.data;
178
+ }
179
+ async function resolveGlossary(glossary, baseDir, fs) {
180
+ if (glossary === void 0) {
181
+ return { glossary: void 0, provenance: { source: "none" } };
182
+ }
183
+ if (typeof glossary === "string") {
184
+ const resolvedPath = resolve(baseDir, glossary);
185
+ const record = await readGlossaryFile(resolvedPath, fs);
186
+ return { glossary: record, provenance: { source: "file", path: resolvedPath } };
187
+ }
188
+ return { glossary, provenance: { source: "inline" } };
189
+ }
41
190
  var FNV_OFFSET_BASIS = 14695981039346656037n;
42
191
  var FNV_PRIME = 1099511628211n;
43
192
  var U64_MASK = (1n << 64n) - 1n;
@@ -873,7 +1022,13 @@ var verbatraConfigSchema = z.strictObject({
873
1022
  pattern: z.string().min(1)
874
1023
  }),
875
1024
  provider: providerConfigSchema,
876
- glossary: z.record(z.string(), z.string()).optional(),
1025
+ /**
1026
+ * A glossary of source terms to preferred target terms, either inline or as a path to a JSON file
1027
+ * carrying the same shape. The two forms are mutually exclusive by type; there is no merge or
1028
+ * precedence between them. A path is resolved and validated by the loader, not by this schema; see
1029
+ * `resolve-glossary.ts`.
1030
+ */
1031
+ glossary: z.union([z.record(z.string(), z.string()), z.string().min(1)]).optional(),
877
1032
  tone: z.enum(["formal", "informal", "neutral"]).optional(),
878
1033
  /**
879
1034
  * Opt-in orphan pruning, off by default. When true, keys present in a target file but absent from
@@ -927,7 +1082,7 @@ function formatIssues(error) {
927
1082
  return issue.code === "unrecognized_keys" ? `${base} (API keys are read from the environment, not the config)` : base;
928
1083
  }).join("; ");
929
1084
  }
930
- function validate(input) {
1085
+ function parseConfig(input) {
931
1086
  const parsed = verbatraConfigSchema.safeParse(input);
932
1087
  if (!parsed.success) {
933
1088
  throw new SdkError(
@@ -937,7 +1092,16 @@ function validate(input) {
937
1092
  }
938
1093
  return parsed.data;
939
1094
  }
940
- async function loadExplicit(explorer, configPath, cwd) {
1095
+ async function finalizeConfig(parsed, baseDir, fs) {
1096
+ const { glossary: glossaryInput, ...rest } = parsed;
1097
+ const resolved = await resolveGlossary(glossaryInput, baseDir, fs);
1098
+ const config = {
1099
+ ...rest,
1100
+ ...resolved.glossary !== void 0 ? { glossary: resolved.glossary } : {}
1101
+ };
1102
+ return { config, glossary: resolved.provenance };
1103
+ }
1104
+ async function loadExplicitWithMeta(explorer, configPath, cwd, fs) {
941
1105
  const resolved = resolve(cwd ?? process.cwd(), configPath);
942
1106
  if (!existsSync(resolved)) {
943
1107
  throw new SdkError("CONFIG_NOT_FOUND", `No verbatra configuration file at ${resolved}.`);
@@ -949,18 +1113,23 @@ async function loadExplicit(explorer, configPath, cwd) {
949
1113
  const detail = error instanceof Error ? error.message : String(error);
950
1114
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
951
1115
  }
952
- return validate(result?.config);
1116
+ const parsed = parseConfig(result?.config);
1117
+ const { config, glossary } = await finalizeConfig(parsed, dirname(resolved), fs);
1118
+ return { config, source: { kind: "explicit", filepath: resolved }, glossary };
953
1119
  }
954
- async function loadConfig(options = {}) {
1120
+ async function loadConfigWithMeta(options = {}) {
1121
+ const fs = options.fs ?? defaultFs;
955
1122
  if (options.configOverride !== void 0) {
956
- return validate(options.configOverride);
1123
+ const parsed2 = parseConfig(options.configOverride);
1124
+ const { config: config2, glossary: glossary2 } = await finalizeConfig(parsed2, options.cwd ?? process.cwd(), fs);
1125
+ return { config: config2, source: { kind: "override" }, glossary: glossary2 };
957
1126
  }
958
1127
  const explorer = cosmiconfig(MODULE_NAME, {
959
1128
  searchPlaces: SEARCH_PLACES,
960
1129
  loaders: { ".ts": TypeScriptLoader() }
961
1130
  });
962
1131
  if (options.configPath !== void 0) {
963
- return loadExplicit(explorer, options.configPath, options.cwd);
1132
+ return loadExplicitWithMeta(explorer, options.configPath, options.cwd, fs);
964
1133
  }
965
1134
  let result;
966
1135
  try {
@@ -975,99 +1144,14 @@ async function loadConfig(options = {}) {
975
1144
  "No verbatra configuration found. Create a verbatra.config.ts, a .verbatrarc.json, or a 'verbatra' property in package.json."
976
1145
  );
977
1146
  }
978
- return validate(result.config);
979
- }
980
- async function readBoundedUtf8(handle, size) {
981
- const buffer = Buffer.allocUnsafe(size);
982
- let offset = 0;
983
- while (offset < size) {
984
- const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
985
- if (bytesRead === 0) {
986
- break;
987
- }
988
- offset += bytesRead;
989
- }
990
- return buffer.toString("utf8", 0, offset);
991
- }
992
- async function readBounded(path, maxBytes) {
993
- let handle;
994
- try {
995
- handle = await open(path, "r");
996
- } catch {
997
- return { kind: "missing" };
998
- }
999
- try {
1000
- const info = await handle.stat();
1001
- if (!info.isFile()) {
1002
- return { kind: "missing" };
1003
- }
1004
- if (info.size > maxBytes) {
1005
- return { kind: "too-large" };
1006
- }
1007
- return { kind: "ok", content: await readBoundedUtf8(handle, info.size) };
1008
- } finally {
1009
- await handle.close();
1010
- }
1011
- }
1012
- async function readBoundedBytesInto(handle, size) {
1013
- const buffer = Buffer.allocUnsafeSlow(size);
1014
- let offset = 0;
1015
- while (offset < size) {
1016
- const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
1017
- if (bytesRead === 0) {
1018
- break;
1019
- }
1020
- offset += bytesRead;
1021
- }
1022
- return new Uint8Array(buffer.buffer, buffer.byteOffset, offset);
1023
- }
1024
- async function readBoundedBytes(path, maxBytes) {
1025
- let handle;
1026
- try {
1027
- handle = await open(path, "r");
1028
- } catch {
1029
- return { kind: "missing" };
1030
- }
1031
- try {
1032
- const info = await handle.stat();
1033
- if (!info.isFile()) {
1034
- return { kind: "missing" };
1035
- }
1036
- if (info.size > maxBytes) {
1037
- return { kind: "too-large" };
1038
- }
1039
- return { kind: "ok", bytes: await readBoundedBytesInto(handle, info.size) };
1040
- } finally {
1041
- await handle.close();
1042
- }
1043
- }
1044
- function tempFileName(path) {
1045
- return join(dirname(path), `.${basename(path)}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`);
1147
+ const parsed = parseConfig(result.config);
1148
+ const { config, glossary } = await finalizeConfig(parsed, dirname(result.filepath), fs);
1149
+ return { config, source: { kind: "search", filepath: result.filepath }, glossary };
1046
1150
  }
1047
- async function atomicWrite(path, data) {
1048
- const tmp = tempFileName(path);
1049
- await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
1050
- try {
1051
- await rename(tmp, path);
1052
- } catch (error) {
1053
- await rm(tmp, { force: true });
1054
- throw error;
1055
- }
1151
+ async function loadConfig(options = {}) {
1152
+ const { config } = await loadConfigWithMeta(options);
1153
+ return config;
1056
1154
  }
1057
- var defaultFs = {
1058
- async fileExists(path) {
1059
- try {
1060
- await access(path);
1061
- return true;
1062
- } catch {
1063
- return false;
1064
- }
1065
- },
1066
- readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
1067
- readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
1068
- writeFile: (path, data) => atomicWrite(path, data),
1069
- writeBytes: (path, data) => atomicWrite(path, data)
1070
- };
1071
1155
  var LOCK_FILE_NAME = "verbatra.lock.json";
1072
1156
  var CURRENT_VERSION = 1;
1073
1157
  var EMPTY_LOCK = { version: CURRENT_VERSION, locales: {} };
@@ -1152,19 +1236,53 @@ function childMessages(element) {
1152
1236
  }
1153
1237
  return [];
1154
1238
  }
1155
- function collect(elements, add, state) {
1239
+ function addCount(target, token, count) {
1240
+ target.set(token, (target.get(token) ?? 0) + count);
1241
+ }
1242
+ function combineBranches(branchMultisets) {
1243
+ const combined = /* @__PURE__ */ new Map();
1244
+ const tokens = /* @__PURE__ */ new Set();
1245
+ for (const branch of branchMultisets) {
1246
+ for (const token of branch.keys()) {
1247
+ tokens.add(token);
1248
+ }
1249
+ }
1250
+ for (const token of tokens) {
1251
+ const minCount = Math.min(...branchMultisets.map((branch) => branch.get(token) ?? 0));
1252
+ if (minCount > 0) {
1253
+ combined.set(token, minCount);
1254
+ }
1255
+ }
1256
+ return combined;
1257
+ }
1258
+ function collect(elements, state) {
1259
+ const result = /* @__PURE__ */ new Map();
1156
1260
  for (const element of elements) {
1157
1261
  const token = tokenOf(element);
1158
1262
  if (token !== void 0) {
1159
- add(token);
1263
+ addCount(result, token, 1);
1160
1264
  }
1161
1265
  if (element.type === TYPE.plural) {
1162
1266
  state.isPlural = true;
1163
1267
  }
1164
- for (const child of childMessages(element)) {
1165
- collect(child, add, state);
1268
+ const branches = childMessages(element);
1269
+ if (branches.length > 0) {
1270
+ const branchMultisets = branches.map((branch) => collect(branch, state));
1271
+ for (const [branchToken, branchCount] of combineBranches(branchMultisets)) {
1272
+ addCount(result, branchToken, branchCount);
1273
+ }
1274
+ }
1275
+ }
1276
+ return result;
1277
+ }
1278
+ function flatten(multiset) {
1279
+ const flattened = [];
1280
+ for (const [token, count] of multiset) {
1281
+ for (let i = 0; i < count; i += 1) {
1282
+ flattened.push(token);
1166
1283
  }
1167
1284
  }
1285
+ return flattened;
1168
1286
  }
1169
1287
  function analyzeIcuValue(value) {
1170
1288
  if (!value.includes("{") && !value.includes("<")) {
@@ -1172,16 +1290,9 @@ function analyzeIcuValue(value) {
1172
1290
  }
1173
1291
  try {
1174
1292
  const ast = parse$1(value);
1175
- const placeholders = [];
1176
1293
  const state = { isPlural: false };
1177
- collect(
1178
- ast,
1179
- (token) => {
1180
- placeholders.push(token);
1181
- },
1182
- state
1183
- );
1184
- return { placeholders, isPlural: state.isPlural, valid: true };
1294
+ const multiset = collect(ast, state);
1295
+ return { placeholders: flatten(multiset), isPlural: state.isPlural, valid: true };
1185
1296
  } catch {
1186
1297
  return INVALID;
1187
1298
  }
@@ -1443,21 +1554,28 @@ function addEntries(ctx, prefix, node) {
1443
1554
  }
1444
1555
  }
1445
1556
  }
1446
- function addPathEntries(node, prefix, namespace, derive, out) {
1557
+ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
1447
1558
  for (const [key, value] of Object.entries(node)) {
1448
1559
  const path = prefix === "" ? key : `${prefix}.${key}`;
1560
+ if (claimedPaths.has(path)) {
1561
+ throw new AdapterError(
1562
+ "INVALID_STRUCTURE",
1563
+ "A dotted key and a nested key path resolve to the same path."
1564
+ );
1565
+ }
1566
+ claimedPaths.add(path);
1449
1567
  if (typeof value === "string") {
1450
1568
  const { placeholders, isPlural } = derive(key, value);
1451
1569
  out.set(path, { key: path, namespace, value, placeholders, isPlural });
1452
1570
  } else {
1453
- addPathEntries(value, path, namespace, derive, out);
1571
+ addPathEntries(value, path, namespace, derive, out, claimedPaths);
1454
1572
  }
1455
1573
  }
1456
1574
  }
1457
1575
  function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
1458
1576
  const out = /* @__PURE__ */ new Map();
1459
1577
  if (keyMode === "path-notation") {
1460
- addPathEntries(tree, "", namespace, derive, out);
1578
+ addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set());
1461
1579
  return out;
1462
1580
  }
1463
1581
  addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
@@ -1579,21 +1697,20 @@ function messagesFromEntries(entries) {
1579
1697
  }
1580
1698
  return out;
1581
1699
  }
1700
+ function isEnoent(error) {
1701
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
1702
+ }
1582
1703
  async function readDestinationPairs(filePath) {
1583
- let parsed;
1704
+ let content;
1584
1705
  try {
1585
- const outcome = await readBounded2(filePath);
1586
- if (outcome.kind !== "ok") {
1706
+ content = await readFileContent(filePath);
1707
+ } catch (error) {
1708
+ if (isEnoent(error)) {
1587
1709
  return null;
1588
1710
  }
1589
- parsed = JSON.parse(outcome.content);
1590
- } catch {
1591
- return null;
1592
- }
1593
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1594
- return null;
1711
+ throw error;
1595
1712
  }
1596
- return Object.entries(parsed);
1713
+ return Object.entries(parseArbObject(content));
1597
1714
  }
1598
1715
  async function buildArbWriteTree(entries, filePath) {
1599
1716
  const messages = messagesFromEntries(entries);
@@ -1694,6 +1811,20 @@ function createNextIntlJsonAdapter() {
1694
1811
  validateMessage: icuIsValid
1695
1812
  });
1696
1813
  }
1814
+ function assertNoDottedNestedKey(tree) {
1815
+ for (const [key, value] of Object.entries(tree)) {
1816
+ if (typeof value !== "object") {
1817
+ continue;
1818
+ }
1819
+ if (key.includes(".")) {
1820
+ throw new AdapterError(
1821
+ "MIXED_STRUCTURE",
1822
+ "A nested object key contains a literal dot, which is ambiguous with a dotted path."
1823
+ );
1824
+ }
1825
+ assertNoDottedNestedKey(value);
1826
+ }
1827
+ }
1697
1828
  function assertNotMixed(tree) {
1698
1829
  let hasNested = false;
1699
1830
  let hasFlatDottedKey = false;
@@ -1710,6 +1841,7 @@ function assertNotMixed(tree) {
1710
1841
  "The file mixes flat dotted keys with nested objects."
1711
1842
  );
1712
1843
  }
1844
+ assertNoDottedNestedKey(tree);
1713
1845
  }
1714
1846
  async function detectStyle(filePath) {
1715
1847
  let parsed;
@@ -2176,6 +2308,40 @@ async function diff(input, deps = {}) {
2176
2308
  return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
2177
2309
  }
2178
2310
 
2311
+ // src/flow/lock-state.ts
2312
+ function toLockLocaleState(locale, keyCount, diff2) {
2313
+ return {
2314
+ locale,
2315
+ keyCount,
2316
+ missing: diff2.missing.length,
2317
+ stale: diff2.changed.length,
2318
+ upToDate: diff2.unchanged.length
2319
+ };
2320
+ }
2321
+ async function lockState(input, deps = {}) {
2322
+ const config = input.config;
2323
+ const cwd = input.cwd ?? process.cwd();
2324
+ const fs = deps.fs ?? defaultFs;
2325
+ const locales = selectLocales(config, input.locales);
2326
+ const path = lockFilePath(cwd);
2327
+ const exists = await fs.fileExists(path);
2328
+ if (!exists) {
2329
+ return { exists: false };
2330
+ }
2331
+ const lock = await readLockFile(path, fs);
2332
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
2333
+ const source = await readSource(config, cwd, fs, adapter);
2334
+ const localeStates = await Promise.all(
2335
+ locales.map(async (locale) => {
2336
+ const target = await readTarget(cwd, config, adapter, fs, locale);
2337
+ const baseline = baselineFor(lock, locale);
2338
+ const diff2 = diffResources(source.resource, target, { baseline });
2339
+ return toLockLocaleState(locale, baseline.size, diff2);
2340
+ })
2341
+ );
2342
+ return { exists: true, version: lock.version, locales: localeStates };
2343
+ }
2344
+
2179
2345
  // src/selection/select-provider.ts
2180
2346
  function selectProvider(config, createProvider = buildProvider) {
2181
2347
  try {
@@ -2207,6 +2373,7 @@ function failureSummary(locale, error) {
2207
2373
  pruned: [],
2208
2374
  invalidIcuSource: [],
2209
2375
  integrityMismatches: [],
2376
+ providerFailures: [],
2210
2377
  generated: [],
2211
2378
  notices: [],
2212
2379
  error: describeError(error)
@@ -2218,6 +2385,29 @@ function partition(locales) {
2218
2385
  return { succeeded, failed };
2219
2386
  }
2220
2387
 
2388
+ // src/flow/batching.ts
2389
+ function chunk(items, size) {
2390
+ const chunks = [];
2391
+ for (let index = 0; index < items.length; index += size) {
2392
+ chunks.push(items.slice(index, index + size));
2393
+ }
2394
+ return chunks;
2395
+ }
2396
+ var GENERIC_PROVIDER_FAILURE_MESSAGE = "The provider call failed.";
2397
+ function classifyProviderFailure(error) {
2398
+ if (error instanceof ProviderError) {
2399
+ return { code: error.code, message: error.message };
2400
+ }
2401
+ return { code: "PROVIDER_CALL_FAILED", message: GENERIC_PROVIDER_FAILURE_MESSAGE };
2402
+ }
2403
+ function subBatchFailedNotice(count, error) {
2404
+ const { code, message } = classifyProviderFailure(error);
2405
+ return {
2406
+ code: "SUB_BATCH_FAILED",
2407
+ message: `A sub-batch of ${count} entries failed (${code}: ${message}) and was withheld; it will be retried next run.`
2408
+ };
2409
+ }
2410
+
2221
2411
  // src/flow/notices.ts
2222
2412
  function isNotice(value) {
2223
2413
  return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
@@ -2400,13 +2590,36 @@ async function generatePluralForms(context) {
2400
2590
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
2401
2591
  const stale = staleItems(plan.items, context.baseline);
2402
2592
  if (stale.length === 0) {
2403
- return { accepted: [], withheld: [] };
2593
+ return { accepted: [], withheld: [], providerFailures: [], notices: [] };
2404
2594
  }
2405
- const entries = stale.map(syntheticEntry);
2406
- const result = await context.provider.translateBatch(buildRequest2(context, entries));
2407
2595
  const accepted = [];
2408
2596
  const withheld = [];
2409
- for (const item of stale) {
2597
+ const providerFailures = [];
2598
+ const notices = [];
2599
+ for (const batch of chunk(stale, context.maxBatchSize)) {
2600
+ const batchNotices = await runGenerationSubBatch(
2601
+ context,
2602
+ batch,
2603
+ accepted,
2604
+ withheld,
2605
+ providerFailures
2606
+ );
2607
+ notices.push(...batchNotices);
2608
+ }
2609
+ return { accepted, withheld, providerFailures, notices };
2610
+ }
2611
+ async function runGenerationSubBatch(context, batch, accepted, withheld, providerFailures) {
2612
+ let result;
2613
+ try {
2614
+ const entries = batch.map(syntheticEntry);
2615
+ result = await context.provider.translateBatch(buildRequest2(context, entries));
2616
+ } catch (error) {
2617
+ for (const item of batch) {
2618
+ providerFailures.push(item.targetKey);
2619
+ }
2620
+ return [subBatchFailedNotice(batch.length, error)];
2621
+ }
2622
+ for (const item of batch) {
2410
2623
  const value = result.values.get(item.targetKey);
2411
2624
  const integrity = result.integrity.get(item.targetKey);
2412
2625
  if (value !== void 0 && integrity?.matches === true) {
@@ -2419,7 +2632,7 @@ async function generatePluralForms(context) {
2419
2632
  withheld.push(item.targetKey);
2420
2633
  }
2421
2634
  }
2422
- return { accepted, withheld };
2635
+ return readNotices(result);
2423
2636
  }
2424
2637
 
2425
2638
  // src/flow/locale-run.ts
@@ -2469,6 +2682,7 @@ async function runLocale(params) {
2469
2682
  translated: toTranslate,
2470
2683
  generated: [],
2471
2684
  integrityMismatches: [],
2685
+ providerFailures: [],
2472
2686
  pruned,
2473
2687
  notices: sdkNotices
2474
2688
  }),
@@ -2478,12 +2692,14 @@ async function runLocale(params) {
2478
2692
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
2479
2693
  const accepted = /* @__PURE__ */ new Map();
2480
2694
  const integrityMismatches = [];
2695
+ const providerFailures = [];
2481
2696
  const subBatchNotices = await translateAndCheck(
2482
2697
  provider,
2483
2698
  params,
2484
2699
  entries,
2485
2700
  accepted,
2486
- integrityMismatches
2701
+ integrityMismatches,
2702
+ providerFailures
2487
2703
  );
2488
2704
  const merged = new Map(target.entries);
2489
2705
  for (const key of pruned) {
@@ -2507,8 +2723,18 @@ async function runLocale(params) {
2507
2723
  path
2508
2724
  );
2509
2725
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
2510
- const notices = [...pluralNotices, ...subBatchNotices];
2511
- const withheld = /* @__PURE__ */ new Set([...integrityMismatches, ...invalidIcuSource, ...generation.withheld]);
2726
+ const notices = [
2727
+ ...pluralNotices,
2728
+ ...subBatchNotices,
2729
+ ...generation.notices
2730
+ ];
2731
+ const withheld = /* @__PURE__ */ new Set([
2732
+ ...integrityMismatches,
2733
+ ...providerFailures,
2734
+ ...invalidIcuSource,
2735
+ ...generation.withheld,
2736
+ ...generation.providerFailures
2737
+ ]);
2512
2738
  return {
2513
2739
  summary: baseSummary({
2514
2740
  locale: params.targetLocale,
@@ -2519,6 +2745,8 @@ async function runLocale(params) {
2519
2745
  generated: generation.accepted.map((form) => form.targetKey).sort(),
2520
2746
  // Withheld generated forms surface alongside withheld translations: both failed integrity.
2521
2747
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
2748
+ // A generation sub-batch whose provider call itself threw is a provider failure, never integrity.
2749
+ providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
2522
2750
  pruned,
2523
2751
  notices
2524
2752
  }),
@@ -2527,7 +2755,7 @@ async function runLocale(params) {
2527
2755
  }
2528
2756
  async function runGeneration(params, provider) {
2529
2757
  if (!params.generatePlurals || provider.kind !== "llm") {
2530
- return { accepted: [], withheld: [] };
2758
+ return { accepted: [], withheld: [], providerFailures: [], notices: [] };
2531
2759
  }
2532
2760
  return generatePluralForms({
2533
2761
  source: params.source,
@@ -2538,7 +2766,8 @@ async function runGeneration(params, provider) {
2538
2766
  provider,
2539
2767
  glossary: params.glossary,
2540
2768
  tone: params.tone,
2541
- baseline: params.baseline
2769
+ baseline: params.baseline,
2770
+ maxBatchSize: params.maxBatchSize
2542
2771
  });
2543
2772
  }
2544
2773
  function pluralNoticeFor(params, merged) {
@@ -2560,27 +2789,35 @@ function baseSummary(parts) {
2560
2789
  pruned: parts.pruned,
2561
2790
  invalidIcuSource: parts.invalidIcuSource,
2562
2791
  integrityMismatches: parts.integrityMismatches,
2792
+ providerFailures: parts.providerFailures,
2563
2793
  generated: parts.generated,
2564
2794
  notices: parts.notices
2565
2795
  };
2566
2796
  }
2567
- async function translateAndCheck(provider, params, entries, accepted, integrityMismatches) {
2797
+ async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures) {
2568
2798
  const notices = [];
2569
2799
  for (const batch of chunk(entries, params.maxBatchSize)) {
2570
- const subNotices = await runSubBatch(provider, params, batch, accepted, integrityMismatches);
2800
+ const subNotices = await runSubBatch(
2801
+ provider,
2802
+ params,
2803
+ batch,
2804
+ accepted,
2805
+ integrityMismatches,
2806
+ providerFailures
2807
+ );
2571
2808
  notices.push(...subNotices);
2572
2809
  }
2573
2810
  return notices;
2574
2811
  }
2575
- async function runSubBatch(provider, params, batch, accepted, integrityMismatches) {
2812
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures) {
2576
2813
  let result;
2577
2814
  try {
2578
2815
  result = await provider.translateBatch(buildRequest3(params, batch));
2579
- } catch {
2816
+ } catch (error) {
2580
2817
  for (const entry of batch) {
2581
- integrityMismatches.push(entry.key);
2818
+ providerFailures.push(entry.key);
2582
2819
  }
2583
- return [subBatchFailedNotice(batch.length)];
2820
+ return [subBatchFailedNotice(batch.length, error)];
2584
2821
  }
2585
2822
  for (const entry of batch) {
2586
2823
  const value = result.values.get(entry.key);
@@ -2593,19 +2830,6 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
2593
2830
  }
2594
2831
  return readNotices(result);
2595
2832
  }
2596
- function subBatchFailedNotice(count) {
2597
- return {
2598
- code: "SUB_BATCH_FAILED",
2599
- message: `A sub-batch of ${count} entries failed and was withheld; it will be retried next run.`
2600
- };
2601
- }
2602
- function chunk(items, size) {
2603
- const chunks = [];
2604
- for (let index = 0; index < items.length; index += size) {
2605
- chunks.push(items.slice(index, index + size));
2606
- }
2607
- return chunks;
2608
- }
2609
2833
  function computeLockEntries(params, merged, withheld, generated) {
2610
2834
  const lockEntries = {};
2611
2835
  const sourceBaseKeys = sourcePluralBaseKeys(params.source);
@@ -2753,6 +2977,7 @@ var COLUMN_WIDTHS = {
2753
2977
  [COLUMN.status]: 12,
2754
2978
  [COLUMN.translation]: 50
2755
2979
  };
2980
+ var TEXT_NUMBER_FORMAT = "@";
2756
2981
  function styleHeader(sheet) {
2757
2982
  const header = sheet.getRow(HEADER_ROW);
2758
2983
  HEADERS.forEach((label, index) => {
@@ -2768,6 +2993,7 @@ function applyColumnGeometry(sheet) {
2768
2993
  sheet.getColumn(Number(column)).width = width;
2769
2994
  }
2770
2995
  sheet.getColumn(COLUMN.sourceHash).hidden = true;
2996
+ sheet.getColumn(COLUMN.translation).numFmt = TEXT_NUMBER_FORMAT;
2771
2997
  sheet.views = [{ state: "frozen", ySplit: HEADER_ROW }];
2772
2998
  }
2773
2999
  function writeRow(sheet, sheetRow) {
@@ -2776,7 +3002,9 @@ function writeRow(sheet, sheetRow) {
2776
3002
  row.getCell(COLUMN.source).value = sheetRow.source;
2777
3003
  row.getCell(COLUMN.current).value = sheetRow.currentTarget;
2778
3004
  row.getCell(COLUMN.status).value = sheetRow.status;
2779
- row.getCell(COLUMN.translation).value = sheetRow.translation === "" ? null : sheetRow.translation;
3005
+ const translationCell = row.getCell(COLUMN.translation);
3006
+ translationCell.numFmt = TEXT_NUMBER_FORMAT;
3007
+ translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
2780
3008
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
2781
3009
  for (let column = COLUMN.key; column <= COLUMN.sourceHash; column += 1) {
2782
3010
  const cell = row.getCell(column);
@@ -3128,9 +3356,20 @@ function judge(row, sourceEntry, adapter) {
3128
3356
  }
3129
3357
  return void 0;
3130
3358
  }
3359
+ function trackBlankDrift(row, params, buckets) {
3360
+ const sourceEntry = params.source.entries.get(row.key);
3361
+ if (sourceEntry === void 0) {
3362
+ return;
3363
+ }
3364
+ const priorHash = params.baseline.get(row.key);
3365
+ if (priorHash !== void 0 && priorHash !== contentHash(sourceEntry)) {
3366
+ buckets.blankDrifted.add(row.key);
3367
+ }
3368
+ }
3131
3369
  function classifyRows(params, buckets) {
3132
3370
  for (const row of params.sheet.rows) {
3133
3371
  if (row.translation === "") {
3372
+ trackBlankDrift(row, params, buckets);
3134
3373
  continue;
3135
3374
  }
3136
3375
  if (isUnknownKey(row, params.source, params.target)) {
@@ -3149,9 +3388,20 @@ function classifyRows(params, buckets) {
3149
3388
  }
3150
3389
  }
3151
3390
  }
3391
+ function blankRowBaselineNotice(count) {
3392
+ return {
3393
+ code: "BLANK_ROW_BASELINE_RETAINED",
3394
+ message: `${count} row(s) were left blank for a key whose source changed since the row's baseline was recorded; the prior baseline was kept so the drift keeps being reported.`
3395
+ };
3396
+ }
3152
3397
  function importLocale(params) {
3153
3398
  const diff2 = diffResources(params.source, params.target, { baseline: params.baseline });
3154
- const buckets = { accepted: /* @__PURE__ */ new Map(), mismatches: [], withheld: /* @__PURE__ */ new Set() };
3399
+ const buckets = {
3400
+ accepted: /* @__PURE__ */ new Map(),
3401
+ mismatches: [],
3402
+ withheld: /* @__PURE__ */ new Set(),
3403
+ blankDrifted: /* @__PURE__ */ new Set()
3404
+ };
3155
3405
  classifyRows(params, buckets);
3156
3406
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
3157
3407
  const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
@@ -3165,9 +3415,11 @@ function importLocale(params) {
3165
3415
  pruned: [],
3166
3416
  invalidIcuSource,
3167
3417
  integrityMismatches: [...buckets.mismatches].sort(),
3418
+ // A workbook import never calls a provider, so a provider-call failure cannot occur here.
3419
+ providerFailures: [],
3168
3420
  // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
3169
3421
  generated: [],
3170
- notices: []
3422
+ notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : []
3171
3423
  };
3172
3424
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
3173
3425
  }
@@ -3201,21 +3453,19 @@ function mergeAccepted(target, accepted) {
3201
3453
  }
3202
3454
  return merged;
3203
3455
  }
3204
- function computeLockEntries2(source, merged, baseline, withheld) {
3456
+ function computeLockEntries2(source, merged, baseline, accepted) {
3205
3457
  const entries = {};
3206
3458
  for (const key of merged.keys()) {
3207
3459
  const sourceEntry = source.entries.get(key);
3208
3460
  if (sourceEntry === void 0) {
3209
3461
  continue;
3210
3462
  }
3211
- if (withheld.has(key)) {
3212
- const prior = baseline.get(key);
3213
- if (prior !== void 0) {
3214
- entries[key] = prior;
3215
- }
3463
+ if (accepted.has(key)) {
3464
+ entries[key] = contentHash(sourceEntry);
3216
3465
  continue;
3217
3466
  }
3218
- entries[key] = contentHash(sourceEntry);
3467
+ const prior = baseline.get(key);
3468
+ entries[key] = prior !== void 0 ? prior : contentHash(sourceEntry);
3219
3469
  }
3220
3470
  return entries;
3221
3471
  }
@@ -3228,7 +3478,7 @@ async function runSheet(ctx, sheet, lock) {
3228
3478
  }
3229
3479
  const target = await readTarget4(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
3230
3480
  const baseline = baselineFor(lock, sheet.locale);
3231
- const { summary, accepted, withheld } = importLocale({
3481
+ const { summary, accepted } = importLocale({
3232
3482
  sheet,
3233
3483
  source: ctx.source,
3234
3484
  target,
@@ -3252,7 +3502,7 @@ async function runSheet(ctx, sheet, lock) {
3252
3502
  path
3253
3503
  );
3254
3504
  }
3255
- return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, withheld) };
3505
+ return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, accepted) };
3256
3506
  }
3257
3507
  async function importWorkbook(input, deps = {}) {
3258
3508
  const config = input.config;
@@ -3297,6 +3547,13 @@ async function importWorkbook(input, deps = {}) {
3297
3547
  return { dryRun, locales: summaries, succeeded, failed };
3298
3548
  }
3299
3549
 
3550
+ // src/lock/load-lock-file.ts
3551
+ async function loadLockFile(input = {}, deps = {}) {
3552
+ const cwd = input.cwd ?? process.cwd();
3553
+ const fs = deps.fs ?? defaultFs;
3554
+ return readLockFile(lockFilePath(cwd), fs);
3555
+ }
3556
+
3300
3557
  // src/scaffolding.ts
3301
3558
  var scaffoldingMetadata = {
3302
3559
  /** Provider id -> the environment variable its API key is read from. Owned by ai-providers. */
@@ -3412,6 +3669,6 @@ async function watch(input, deps = {}) {
3412
3669
  return { stop };
3413
3670
  }
3414
3671
 
3415
- export { DEFAULT_WORKBOOK_PATH, SdkError, check, defineConfig, diff, exportWorkbook, importWorkbook, loadConfig, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
3672
+ export { DEFAULT_WORKBOOK_PATH, LOCK_FILE_NAME, SdkError, check, defineConfig, diff, exportWorkbook, importWorkbook, loadConfig, loadConfigWithMeta, loadLockFile, lockState, scaffoldingMetadata, translate2 as translate, verbatraConfigSchema, watch };
3416
3673
  //# sourceMappingURL=index.js.map
3417
3674
  //# sourceMappingURL=index.js.map