@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.cjs CHANGED
@@ -4,6 +4,8 @@ var fs = require('fs');
4
4
  var path = require('path');
5
5
  var cosmiconfig = require('cosmiconfig');
6
6
  var cosmiconfigTypescriptLoader = require('cosmiconfig-typescript-loader');
7
+ var crypto = require('crypto');
8
+ var promises = require('fs/promises');
7
9
  var zod = require('zod');
8
10
  var Anthropic = require('@anthropic-ai/sdk');
9
11
  var deepl = require('deepl-node');
@@ -11,8 +13,6 @@ var module$1 = require('module');
11
13
  var log = require('loglevel');
12
14
  var genai = require('@google/genai');
13
15
  var OpenAI = require('openai');
14
- var crypto = require('crypto');
15
- var promises = require('fs/promises');
16
16
  var icuMessageformatParser = require('@formatjs/icu-messageformat-parser');
17
17
  var xmldom = require('@xmldom/xmldom');
18
18
  var yaml = require('yaml');
@@ -68,6 +68,155 @@ var SdkError = class extends Error {
68
68
  this.code = code;
69
69
  }
70
70
  };
71
+ async function readBoundedUtf8(handle, size) {
72
+ const buffer = Buffer.allocUnsafe(size);
73
+ let offset = 0;
74
+ while (offset < size) {
75
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
76
+ if (bytesRead === 0) {
77
+ break;
78
+ }
79
+ offset += bytesRead;
80
+ }
81
+ return buffer.toString("utf8", 0, offset);
82
+ }
83
+ async function readBounded(path, maxBytes) {
84
+ let handle;
85
+ try {
86
+ handle = await promises.open(path, "r");
87
+ } catch {
88
+ return { kind: "missing" };
89
+ }
90
+ try {
91
+ const info = await handle.stat();
92
+ if (!info.isFile()) {
93
+ return { kind: "missing" };
94
+ }
95
+ if (info.size > maxBytes) {
96
+ return { kind: "too-large" };
97
+ }
98
+ return { kind: "ok", content: await readBoundedUtf8(handle, info.size) };
99
+ } finally {
100
+ await handle.close();
101
+ }
102
+ }
103
+ async function readBoundedBytesInto(handle, size) {
104
+ const buffer = Buffer.allocUnsafeSlow(size);
105
+ let offset = 0;
106
+ while (offset < size) {
107
+ const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
108
+ if (bytesRead === 0) {
109
+ break;
110
+ }
111
+ offset += bytesRead;
112
+ }
113
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, offset);
114
+ }
115
+ async function readBoundedBytes(path, maxBytes) {
116
+ let handle;
117
+ try {
118
+ handle = await promises.open(path, "r");
119
+ } catch {
120
+ return { kind: "missing" };
121
+ }
122
+ try {
123
+ const info = await handle.stat();
124
+ if (!info.isFile()) {
125
+ return { kind: "missing" };
126
+ }
127
+ if (info.size > maxBytes) {
128
+ return { kind: "too-large" };
129
+ }
130
+ return { kind: "ok", bytes: await readBoundedBytesInto(handle, info.size) };
131
+ } finally {
132
+ await handle.close();
133
+ }
134
+ }
135
+ function tempFileName(path$1) {
136
+ return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
137
+ }
138
+ async function atomicWrite(path, data) {
139
+ const tmp = tempFileName(path);
140
+ await (typeof data === "string" ? promises.writeFile(tmp, data, "utf8") : promises.writeFile(tmp, data));
141
+ try {
142
+ await promises.rename(tmp, path);
143
+ } catch (error) {
144
+ await promises.rm(tmp, { force: true });
145
+ throw error;
146
+ }
147
+ }
148
+ var defaultFs = {
149
+ async fileExists(path) {
150
+ try {
151
+ await promises.access(path);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ },
157
+ readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
158
+ readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
159
+ writeFile: (path, data) => atomicWrite(path, data),
160
+ writeBytes: (path, data) => atomicWrite(path, data)
161
+ };
162
+ var MAX_GLOSSARY_FILE_BYTES = 1024 * 1024;
163
+ var glossaryRecordSchema = zod.z.record(zod.z.string(), zod.z.string());
164
+ var BOM = "\uFEFF";
165
+ var REPLACEMENT_CHARACTER = "\uFFFD";
166
+ var NUL = "\0";
167
+ function stripBom(content) {
168
+ return content.startsWith(BOM) ? content.slice(BOM.length) : content;
169
+ }
170
+ function looksLikeInvalidEncoding(content) {
171
+ return content.startsWith(REPLACEMENT_CHARACTER) || content.includes(NUL);
172
+ }
173
+ async function readGlossaryFile(resolvedPath, fs) {
174
+ const read = await fs.readFileBounded(resolvedPath, MAX_GLOSSARY_FILE_BYTES);
175
+ if (read.kind === "missing") {
176
+ throw new SdkError(
177
+ "CONFIG_INVALID",
178
+ `The glossary file at ${resolvedPath} was not found or could not be read.`
179
+ );
180
+ }
181
+ if (read.kind === "too-large") {
182
+ throw new SdkError(
183
+ "CONFIG_INVALID",
184
+ `The glossary file at ${resolvedPath} exceeds the maximum allowed size of ${MAX_GLOSSARY_FILE_BYTES} bytes.`
185
+ );
186
+ }
187
+ const content = stripBom(read.content);
188
+ if (looksLikeInvalidEncoding(content)) {
189
+ throw new SdkError(
190
+ "CONFIG_INVALID",
191
+ `The glossary file at ${resolvedPath} must be UTF-8 encoded.`
192
+ );
193
+ }
194
+ let parsed;
195
+ try {
196
+ parsed = JSON.parse(content);
197
+ } catch {
198
+ throw new SdkError("CONFIG_INVALID", `The glossary file at ${resolvedPath} is not valid JSON.`);
199
+ }
200
+ const result = glossaryRecordSchema.safeParse(parsed);
201
+ if (!result.success) {
202
+ throw new SdkError(
203
+ "CONFIG_INVALID",
204
+ `The glossary file at ${resolvedPath} must contain a flat object of string keys to string values.`
205
+ );
206
+ }
207
+ return result.data;
208
+ }
209
+ async function resolveGlossary(glossary, baseDir, fs) {
210
+ if (glossary === void 0) {
211
+ return { glossary: void 0, provenance: { source: "none" } };
212
+ }
213
+ if (typeof glossary === "string") {
214
+ const resolvedPath = path.resolve(baseDir, glossary);
215
+ const record = await readGlossaryFile(resolvedPath, fs);
216
+ return { glossary: record, provenance: { source: "file", path: resolvedPath } };
217
+ }
218
+ return { glossary, provenance: { source: "inline" } };
219
+ }
71
220
  var FNV_OFFSET_BASIS = 14695981039346656037n;
72
221
  var FNV_PRIME = 1099511628211n;
73
222
  var U64_MASK = (1n << 64n) - 1n;
@@ -903,7 +1052,13 @@ var verbatraConfigSchema = zod.z.strictObject({
903
1052
  pattern: zod.z.string().min(1)
904
1053
  }),
905
1054
  provider: providerConfigSchema,
906
- glossary: zod.z.record(zod.z.string(), zod.z.string()).optional(),
1055
+ /**
1056
+ * A glossary of source terms to preferred target terms, either inline or as a path to a JSON file
1057
+ * carrying the same shape. The two forms are mutually exclusive by type; there is no merge or
1058
+ * precedence between them. A path is resolved and validated by the loader, not by this schema; see
1059
+ * `resolve-glossary.ts`.
1060
+ */
1061
+ glossary: zod.z.union([zod.z.record(zod.z.string(), zod.z.string()), zod.z.string().min(1)]).optional(),
907
1062
  tone: zod.z.enum(["formal", "informal", "neutral"]).optional(),
908
1063
  /**
909
1064
  * Opt-in orphan pruning, off by default. When true, keys present in a target file but absent from
@@ -957,7 +1112,7 @@ function formatIssues(error) {
957
1112
  return issue.code === "unrecognized_keys" ? `${base} (API keys are read from the environment, not the config)` : base;
958
1113
  }).join("; ");
959
1114
  }
960
- function validate(input) {
1115
+ function parseConfig(input) {
961
1116
  const parsed = verbatraConfigSchema.safeParse(input);
962
1117
  if (!parsed.success) {
963
1118
  throw new SdkError(
@@ -967,7 +1122,16 @@ function validate(input) {
967
1122
  }
968
1123
  return parsed.data;
969
1124
  }
970
- async function loadExplicit(explorer, configPath, cwd) {
1125
+ async function finalizeConfig(parsed, baseDir, fs) {
1126
+ const { glossary: glossaryInput, ...rest } = parsed;
1127
+ const resolved = await resolveGlossary(glossaryInput, baseDir, fs);
1128
+ const config = {
1129
+ ...rest,
1130
+ ...resolved.glossary !== void 0 ? { glossary: resolved.glossary } : {}
1131
+ };
1132
+ return { config, glossary: resolved.provenance };
1133
+ }
1134
+ async function loadExplicitWithMeta(explorer, configPath, cwd, fs$1) {
971
1135
  const resolved = path.resolve(cwd ?? process.cwd(), configPath);
972
1136
  if (!fs.existsSync(resolved)) {
973
1137
  throw new SdkError("CONFIG_NOT_FOUND", `No verbatra configuration file at ${resolved}.`);
@@ -979,18 +1143,23 @@ async function loadExplicit(explorer, configPath, cwd) {
979
1143
  const detail = error instanceof Error ? error.message : String(error);
980
1144
  throw new SdkError("CONFIG_INVALID", `Failed to load the verbatra configuration: ${detail}`);
981
1145
  }
982
- return validate(result?.config);
1146
+ const parsed = parseConfig(result?.config);
1147
+ const { config, glossary } = await finalizeConfig(parsed, path.dirname(resolved), fs$1);
1148
+ return { config, source: { kind: "explicit", filepath: resolved }, glossary };
983
1149
  }
984
- async function loadConfig(options = {}) {
1150
+ async function loadConfigWithMeta(options = {}) {
1151
+ const fs = options.fs ?? defaultFs;
985
1152
  if (options.configOverride !== void 0) {
986
- return validate(options.configOverride);
1153
+ const parsed2 = parseConfig(options.configOverride);
1154
+ const { config: config2, glossary: glossary2 } = await finalizeConfig(parsed2, options.cwd ?? process.cwd(), fs);
1155
+ return { config: config2, source: { kind: "override" }, glossary: glossary2 };
987
1156
  }
988
1157
  const explorer = cosmiconfig.cosmiconfig(MODULE_NAME, {
989
1158
  searchPlaces: SEARCH_PLACES,
990
1159
  loaders: { ".ts": cosmiconfigTypescriptLoader.TypeScriptLoader() }
991
1160
  });
992
1161
  if (options.configPath !== void 0) {
993
- return loadExplicit(explorer, options.configPath, options.cwd);
1162
+ return loadExplicitWithMeta(explorer, options.configPath, options.cwd, fs);
994
1163
  }
995
1164
  let result;
996
1165
  try {
@@ -1005,99 +1174,14 @@ async function loadConfig(options = {}) {
1005
1174
  "No verbatra configuration found. Create a verbatra.config.ts, a .verbatrarc.json, or a 'verbatra' property in package.json."
1006
1175
  );
1007
1176
  }
1008
- return validate(result.config);
1009
- }
1010
- async function readBoundedUtf8(handle, size) {
1011
- const buffer = Buffer.allocUnsafe(size);
1012
- let offset = 0;
1013
- while (offset < size) {
1014
- const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
1015
- if (bytesRead === 0) {
1016
- break;
1017
- }
1018
- offset += bytesRead;
1019
- }
1020
- return buffer.toString("utf8", 0, offset);
1021
- }
1022
- async function readBounded(path, maxBytes) {
1023
- let handle;
1024
- try {
1025
- handle = await promises.open(path, "r");
1026
- } catch {
1027
- return { kind: "missing" };
1028
- }
1029
- try {
1030
- const info = await handle.stat();
1031
- if (!info.isFile()) {
1032
- return { kind: "missing" };
1033
- }
1034
- if (info.size > maxBytes) {
1035
- return { kind: "too-large" };
1036
- }
1037
- return { kind: "ok", content: await readBoundedUtf8(handle, info.size) };
1038
- } finally {
1039
- await handle.close();
1040
- }
1041
- }
1042
- async function readBoundedBytesInto(handle, size) {
1043
- const buffer = Buffer.allocUnsafeSlow(size);
1044
- let offset = 0;
1045
- while (offset < size) {
1046
- const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
1047
- if (bytesRead === 0) {
1048
- break;
1049
- }
1050
- offset += bytesRead;
1051
- }
1052
- return new Uint8Array(buffer.buffer, buffer.byteOffset, offset);
1053
- }
1054
- async function readBoundedBytes(path, maxBytes) {
1055
- let handle;
1056
- try {
1057
- handle = await promises.open(path, "r");
1058
- } catch {
1059
- return { kind: "missing" };
1060
- }
1061
- try {
1062
- const info = await handle.stat();
1063
- if (!info.isFile()) {
1064
- return { kind: "missing" };
1065
- }
1066
- if (info.size > maxBytes) {
1067
- return { kind: "too-large" };
1068
- }
1069
- return { kind: "ok", bytes: await readBoundedBytesInto(handle, info.size) };
1070
- } finally {
1071
- await handle.close();
1072
- }
1073
- }
1074
- function tempFileName(path$1) {
1075
- return path.join(path.dirname(path$1), `.${path.basename(path$1)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`);
1177
+ const parsed = parseConfig(result.config);
1178
+ const { config, glossary } = await finalizeConfig(parsed, path.dirname(result.filepath), fs);
1179
+ return { config, source: { kind: "search", filepath: result.filepath }, glossary };
1076
1180
  }
1077
- async function atomicWrite(path, data) {
1078
- const tmp = tempFileName(path);
1079
- await (typeof data === "string" ? promises.writeFile(tmp, data, "utf8") : promises.writeFile(tmp, data));
1080
- try {
1081
- await promises.rename(tmp, path);
1082
- } catch (error) {
1083
- await promises.rm(tmp, { force: true });
1084
- throw error;
1085
- }
1181
+ async function loadConfig(options = {}) {
1182
+ const { config } = await loadConfigWithMeta(options);
1183
+ return config;
1086
1184
  }
1087
- var defaultFs = {
1088
- async fileExists(path) {
1089
- try {
1090
- await promises.access(path);
1091
- return true;
1092
- } catch {
1093
- return false;
1094
- }
1095
- },
1096
- readFileBounded: (path, maxBytes) => readBounded(path, maxBytes),
1097
- readBytesBounded: (path, maxBytes) => readBoundedBytes(path, maxBytes),
1098
- writeFile: (path, data) => atomicWrite(path, data),
1099
- writeBytes: (path, data) => atomicWrite(path, data)
1100
- };
1101
1185
  var LOCK_FILE_NAME = "verbatra.lock.json";
1102
1186
  var CURRENT_VERSION = 1;
1103
1187
  var EMPTY_LOCK = { version: CURRENT_VERSION, locales: {} };
@@ -1182,19 +1266,53 @@ function childMessages(element) {
1182
1266
  }
1183
1267
  return [];
1184
1268
  }
1185
- function collect(elements, add, state) {
1269
+ function addCount(target, token, count) {
1270
+ target.set(token, (target.get(token) ?? 0) + count);
1271
+ }
1272
+ function combineBranches(branchMultisets) {
1273
+ const combined = /* @__PURE__ */ new Map();
1274
+ const tokens = /* @__PURE__ */ new Set();
1275
+ for (const branch of branchMultisets) {
1276
+ for (const token of branch.keys()) {
1277
+ tokens.add(token);
1278
+ }
1279
+ }
1280
+ for (const token of tokens) {
1281
+ const minCount = Math.min(...branchMultisets.map((branch) => branch.get(token) ?? 0));
1282
+ if (minCount > 0) {
1283
+ combined.set(token, minCount);
1284
+ }
1285
+ }
1286
+ return combined;
1287
+ }
1288
+ function collect(elements, state) {
1289
+ const result = /* @__PURE__ */ new Map();
1186
1290
  for (const element of elements) {
1187
1291
  const token = tokenOf(element);
1188
1292
  if (token !== void 0) {
1189
- add(token);
1293
+ addCount(result, token, 1);
1190
1294
  }
1191
1295
  if (element.type === icuMessageformatParser.TYPE.plural) {
1192
1296
  state.isPlural = true;
1193
1297
  }
1194
- for (const child of childMessages(element)) {
1195
- collect(child, add, state);
1298
+ const branches = childMessages(element);
1299
+ if (branches.length > 0) {
1300
+ const branchMultisets = branches.map((branch) => collect(branch, state));
1301
+ for (const [branchToken, branchCount] of combineBranches(branchMultisets)) {
1302
+ addCount(result, branchToken, branchCount);
1303
+ }
1304
+ }
1305
+ }
1306
+ return result;
1307
+ }
1308
+ function flatten(multiset) {
1309
+ const flattened = [];
1310
+ for (const [token, count] of multiset) {
1311
+ for (let i = 0; i < count; i += 1) {
1312
+ flattened.push(token);
1196
1313
  }
1197
1314
  }
1315
+ return flattened;
1198
1316
  }
1199
1317
  function analyzeIcuValue(value) {
1200
1318
  if (!value.includes("{") && !value.includes("<")) {
@@ -1202,16 +1320,9 @@ function analyzeIcuValue(value) {
1202
1320
  }
1203
1321
  try {
1204
1322
  const ast = icuMessageformatParser.parse(value);
1205
- const placeholders = [];
1206
1323
  const state = { isPlural: false };
1207
- collect(
1208
- ast,
1209
- (token) => {
1210
- placeholders.push(token);
1211
- },
1212
- state
1213
- );
1214
- return { placeholders, isPlural: state.isPlural, valid: true };
1324
+ const multiset = collect(ast, state);
1325
+ return { placeholders: flatten(multiset), isPlural: state.isPlural, valid: true };
1215
1326
  } catch {
1216
1327
  return INVALID;
1217
1328
  }
@@ -1473,21 +1584,28 @@ function addEntries(ctx, prefix, node) {
1473
1584
  }
1474
1585
  }
1475
1586
  }
1476
- function addPathEntries(node, prefix, namespace, derive, out) {
1587
+ function addPathEntries(node, prefix, namespace, derive, out, claimedPaths) {
1477
1588
  for (const [key, value] of Object.entries(node)) {
1478
1589
  const path = prefix === "" ? key : `${prefix}.${key}`;
1590
+ if (claimedPaths.has(path)) {
1591
+ throw new AdapterError(
1592
+ "INVALID_STRUCTURE",
1593
+ "A dotted key and a nested key path resolve to the same path."
1594
+ );
1595
+ }
1596
+ claimedPaths.add(path);
1479
1597
  if (typeof value === "string") {
1480
1598
  const { placeholders, isPlural } = derive(key, value);
1481
1599
  out.set(path, { key: path, namespace, value, placeholders, isPlural });
1482
1600
  } else {
1483
- addPathEntries(value, path, namespace, derive, out);
1601
+ addPathEntries(value, path, namespace, derive, out, claimedPaths);
1484
1602
  }
1485
1603
  }
1486
1604
  }
1487
1605
  function flattenTree(tree, namespace, derive, keyMode = "literal-leaf") {
1488
1606
  const out = /* @__PURE__ */ new Map();
1489
1607
  if (keyMode === "path-notation") {
1490
- addPathEntries(tree, "", namespace, derive, out);
1608
+ addPathEntries(tree, "", namespace, derive, out, /* @__PURE__ */ new Set());
1491
1609
  return out;
1492
1610
  }
1493
1611
  addEntries({ namespace, derive, out, claimed: /* @__PURE__ */ new Map() }, [], tree);
@@ -1609,21 +1727,20 @@ function messagesFromEntries(entries) {
1609
1727
  }
1610
1728
  return out;
1611
1729
  }
1730
+ function isEnoent(error) {
1731
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
1732
+ }
1612
1733
  async function readDestinationPairs(filePath) {
1613
- let parsed;
1734
+ let content;
1614
1735
  try {
1615
- const outcome = await readBounded2(filePath);
1616
- if (outcome.kind !== "ok") {
1736
+ content = await readFileContent(filePath);
1737
+ } catch (error) {
1738
+ if (isEnoent(error)) {
1617
1739
  return null;
1618
1740
  }
1619
- parsed = JSON.parse(outcome.content);
1620
- } catch {
1621
- return null;
1622
- }
1623
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1624
- return null;
1741
+ throw error;
1625
1742
  }
1626
- return Object.entries(parsed);
1743
+ return Object.entries(parseArbObject(content));
1627
1744
  }
1628
1745
  async function buildArbWriteTree(entries, filePath) {
1629
1746
  const messages = messagesFromEntries(entries);
@@ -1724,6 +1841,20 @@ function createNextIntlJsonAdapter() {
1724
1841
  validateMessage: icuIsValid
1725
1842
  });
1726
1843
  }
1844
+ function assertNoDottedNestedKey(tree) {
1845
+ for (const [key, value] of Object.entries(tree)) {
1846
+ if (typeof value !== "object") {
1847
+ continue;
1848
+ }
1849
+ if (key.includes(".")) {
1850
+ throw new AdapterError(
1851
+ "MIXED_STRUCTURE",
1852
+ "A nested object key contains a literal dot, which is ambiguous with a dotted path."
1853
+ );
1854
+ }
1855
+ assertNoDottedNestedKey(value);
1856
+ }
1857
+ }
1727
1858
  function assertNotMixed(tree) {
1728
1859
  let hasNested = false;
1729
1860
  let hasFlatDottedKey = false;
@@ -1740,6 +1871,7 @@ function assertNotMixed(tree) {
1740
1871
  "The file mixes flat dotted keys with nested objects."
1741
1872
  );
1742
1873
  }
1874
+ assertNoDottedNestedKey(tree);
1743
1875
  }
1744
1876
  async function detectStyle(filePath) {
1745
1877
  let parsed;
@@ -2206,6 +2338,40 @@ async function diff(input, deps = {}) {
2206
2338
  return { hasPendingChanges: locales.some((entry) => entry.hasPendingChanges), locales };
2207
2339
  }
2208
2340
 
2341
+ // src/flow/lock-state.ts
2342
+ function toLockLocaleState(locale, keyCount, diff2) {
2343
+ return {
2344
+ locale,
2345
+ keyCount,
2346
+ missing: diff2.missing.length,
2347
+ stale: diff2.changed.length,
2348
+ upToDate: diff2.unchanged.length
2349
+ };
2350
+ }
2351
+ async function lockState(input, deps = {}) {
2352
+ const config = input.config;
2353
+ const cwd = input.cwd ?? process.cwd();
2354
+ const fs = deps.fs ?? defaultFs;
2355
+ const locales = selectLocales(config, input.locales);
2356
+ const path = lockFilePath(cwd);
2357
+ const exists = await fs.fileExists(path);
2358
+ if (!exists) {
2359
+ return { exists: false };
2360
+ }
2361
+ const lock = await readLockFile(path, fs);
2362
+ const adapter = selectAdapter(config.format, deps.adapterRegistry);
2363
+ const source = await readSource(config, cwd, fs, adapter);
2364
+ const localeStates = await Promise.all(
2365
+ locales.map(async (locale) => {
2366
+ const target = await readTarget(cwd, config, adapter, fs, locale);
2367
+ const baseline = baselineFor(lock, locale);
2368
+ const diff2 = diffResources(source.resource, target, { baseline });
2369
+ return toLockLocaleState(locale, baseline.size, diff2);
2370
+ })
2371
+ );
2372
+ return { exists: true, version: lock.version, locales: localeStates };
2373
+ }
2374
+
2209
2375
  // src/selection/select-provider.ts
2210
2376
  function selectProvider(config, createProvider = buildProvider) {
2211
2377
  try {
@@ -2237,6 +2403,7 @@ function failureSummary(locale, error) {
2237
2403
  pruned: [],
2238
2404
  invalidIcuSource: [],
2239
2405
  integrityMismatches: [],
2406
+ providerFailures: [],
2240
2407
  generated: [],
2241
2408
  notices: [],
2242
2409
  error: describeError(error)
@@ -2248,6 +2415,29 @@ function partition(locales) {
2248
2415
  return { succeeded, failed };
2249
2416
  }
2250
2417
 
2418
+ // src/flow/batching.ts
2419
+ function chunk(items, size) {
2420
+ const chunks = [];
2421
+ for (let index = 0; index < items.length; index += size) {
2422
+ chunks.push(items.slice(index, index + size));
2423
+ }
2424
+ return chunks;
2425
+ }
2426
+ var GENERIC_PROVIDER_FAILURE_MESSAGE = "The provider call failed.";
2427
+ function classifyProviderFailure(error) {
2428
+ if (error instanceof ProviderError) {
2429
+ return { code: error.code, message: error.message };
2430
+ }
2431
+ return { code: "PROVIDER_CALL_FAILED", message: GENERIC_PROVIDER_FAILURE_MESSAGE };
2432
+ }
2433
+ function subBatchFailedNotice(count, error) {
2434
+ const { code, message } = classifyProviderFailure(error);
2435
+ return {
2436
+ code: "SUB_BATCH_FAILED",
2437
+ message: `A sub-batch of ${count} entries failed (${code}: ${message}) and was withheld; it will be retried next run.`
2438
+ };
2439
+ }
2440
+
2251
2441
  // src/flow/notices.ts
2252
2442
  function isNotice(value) {
2253
2443
  return typeof value === "object" && value !== null && typeof value.code === "string" && typeof value.message === "string";
@@ -2430,13 +2620,36 @@ async function generatePluralForms(context) {
2430
2620
  const plan = planPluralGeneration(context.source, context.targetLocale, context.format);
2431
2621
  const stale = staleItems(plan.items, context.baseline);
2432
2622
  if (stale.length === 0) {
2433
- return { accepted: [], withheld: [] };
2623
+ return { accepted: [], withheld: [], providerFailures: [], notices: [] };
2434
2624
  }
2435
- const entries = stale.map(syntheticEntry);
2436
- const result = await context.provider.translateBatch(buildRequest2(context, entries));
2437
2625
  const accepted = [];
2438
2626
  const withheld = [];
2439
- for (const item of stale) {
2627
+ const providerFailures = [];
2628
+ const notices = [];
2629
+ for (const batch of chunk(stale, context.maxBatchSize)) {
2630
+ const batchNotices = await runGenerationSubBatch(
2631
+ context,
2632
+ batch,
2633
+ accepted,
2634
+ withheld,
2635
+ providerFailures
2636
+ );
2637
+ notices.push(...batchNotices);
2638
+ }
2639
+ return { accepted, withheld, providerFailures, notices };
2640
+ }
2641
+ async function runGenerationSubBatch(context, batch, accepted, withheld, providerFailures) {
2642
+ let result;
2643
+ try {
2644
+ const entries = batch.map(syntheticEntry);
2645
+ result = await context.provider.translateBatch(buildRequest2(context, entries));
2646
+ } catch (error) {
2647
+ for (const item of batch) {
2648
+ providerFailures.push(item.targetKey);
2649
+ }
2650
+ return [subBatchFailedNotice(batch.length, error)];
2651
+ }
2652
+ for (const item of batch) {
2440
2653
  const value = result.values.get(item.targetKey);
2441
2654
  const integrity = result.integrity.get(item.targetKey);
2442
2655
  if (value !== void 0 && integrity?.matches === true) {
@@ -2449,7 +2662,7 @@ async function generatePluralForms(context) {
2449
2662
  withheld.push(item.targetKey);
2450
2663
  }
2451
2664
  }
2452
- return { accepted, withheld };
2665
+ return readNotices(result);
2453
2666
  }
2454
2667
 
2455
2668
  // src/flow/locale-run.ts
@@ -2499,6 +2712,7 @@ async function runLocale(params) {
2499
2712
  translated: toTranslate,
2500
2713
  generated: [],
2501
2714
  integrityMismatches: [],
2715
+ providerFailures: [],
2502
2716
  pruned,
2503
2717
  notices: sdkNotices
2504
2718
  }),
@@ -2508,12 +2722,14 @@ async function runLocale(params) {
2508
2722
  const entries = toTranslate.map((key) => params.source.entries.get(key)).filter((entry) => entry !== void 0);
2509
2723
  const accepted = /* @__PURE__ */ new Map();
2510
2724
  const integrityMismatches = [];
2725
+ const providerFailures = [];
2511
2726
  const subBatchNotices = await translateAndCheck(
2512
2727
  provider,
2513
2728
  params,
2514
2729
  entries,
2515
2730
  accepted,
2516
- integrityMismatches
2731
+ integrityMismatches,
2732
+ providerFailures
2517
2733
  );
2518
2734
  const merged = new Map(target.entries);
2519
2735
  for (const key of pruned) {
@@ -2537,8 +2753,18 @@ async function runLocale(params) {
2537
2753
  path
2538
2754
  );
2539
2755
  const pluralNotices = params.generatePlurals ? pluralNoticeFor(params, merged) : sdkNotices;
2540
- const notices = [...pluralNotices, ...subBatchNotices];
2541
- const withheld = /* @__PURE__ */ new Set([...integrityMismatches, ...invalidIcuSource, ...generation.withheld]);
2756
+ const notices = [
2757
+ ...pluralNotices,
2758
+ ...subBatchNotices,
2759
+ ...generation.notices
2760
+ ];
2761
+ const withheld = /* @__PURE__ */ new Set([
2762
+ ...integrityMismatches,
2763
+ ...providerFailures,
2764
+ ...invalidIcuSource,
2765
+ ...generation.withheld,
2766
+ ...generation.providerFailures
2767
+ ]);
2542
2768
  return {
2543
2769
  summary: baseSummary({
2544
2770
  locale: params.targetLocale,
@@ -2549,6 +2775,8 @@ async function runLocale(params) {
2549
2775
  generated: generation.accepted.map((form) => form.targetKey).sort(),
2550
2776
  // Withheld generated forms surface alongside withheld translations: both failed integrity.
2551
2777
  integrityMismatches: [...integrityMismatches, ...generation.withheld].sort(),
2778
+ // A generation sub-batch whose provider call itself threw is a provider failure, never integrity.
2779
+ providerFailures: [...providerFailures, ...generation.providerFailures].sort(),
2552
2780
  pruned,
2553
2781
  notices
2554
2782
  }),
@@ -2557,7 +2785,7 @@ async function runLocale(params) {
2557
2785
  }
2558
2786
  async function runGeneration(params, provider) {
2559
2787
  if (!params.generatePlurals || provider.kind !== "llm") {
2560
- return { accepted: [], withheld: [] };
2788
+ return { accepted: [], withheld: [], providerFailures: [], notices: [] };
2561
2789
  }
2562
2790
  return generatePluralForms({
2563
2791
  source: params.source,
@@ -2568,7 +2796,8 @@ async function runGeneration(params, provider) {
2568
2796
  provider,
2569
2797
  glossary: params.glossary,
2570
2798
  tone: params.tone,
2571
- baseline: params.baseline
2799
+ baseline: params.baseline,
2800
+ maxBatchSize: params.maxBatchSize
2572
2801
  });
2573
2802
  }
2574
2803
  function pluralNoticeFor(params, merged) {
@@ -2590,27 +2819,35 @@ function baseSummary(parts) {
2590
2819
  pruned: parts.pruned,
2591
2820
  invalidIcuSource: parts.invalidIcuSource,
2592
2821
  integrityMismatches: parts.integrityMismatches,
2822
+ providerFailures: parts.providerFailures,
2593
2823
  generated: parts.generated,
2594
2824
  notices: parts.notices
2595
2825
  };
2596
2826
  }
2597
- async function translateAndCheck(provider, params, entries, accepted, integrityMismatches) {
2827
+ async function translateAndCheck(provider, params, entries, accepted, integrityMismatches, providerFailures) {
2598
2828
  const notices = [];
2599
2829
  for (const batch of chunk(entries, params.maxBatchSize)) {
2600
- const subNotices = await runSubBatch(provider, params, batch, accepted, integrityMismatches);
2830
+ const subNotices = await runSubBatch(
2831
+ provider,
2832
+ params,
2833
+ batch,
2834
+ accepted,
2835
+ integrityMismatches,
2836
+ providerFailures
2837
+ );
2601
2838
  notices.push(...subNotices);
2602
2839
  }
2603
2840
  return notices;
2604
2841
  }
2605
- async function runSubBatch(provider, params, batch, accepted, integrityMismatches) {
2842
+ async function runSubBatch(provider, params, batch, accepted, integrityMismatches, providerFailures) {
2606
2843
  let result;
2607
2844
  try {
2608
2845
  result = await provider.translateBatch(buildRequest3(params, batch));
2609
- } catch {
2846
+ } catch (error) {
2610
2847
  for (const entry of batch) {
2611
- integrityMismatches.push(entry.key);
2848
+ providerFailures.push(entry.key);
2612
2849
  }
2613
- return [subBatchFailedNotice(batch.length)];
2850
+ return [subBatchFailedNotice(batch.length, error)];
2614
2851
  }
2615
2852
  for (const entry of batch) {
2616
2853
  const value = result.values.get(entry.key);
@@ -2623,19 +2860,6 @@ async function runSubBatch(provider, params, batch, accepted, integrityMismatche
2623
2860
  }
2624
2861
  return readNotices(result);
2625
2862
  }
2626
- function subBatchFailedNotice(count) {
2627
- return {
2628
- code: "SUB_BATCH_FAILED",
2629
- message: `A sub-batch of ${count} entries failed and was withheld; it will be retried next run.`
2630
- };
2631
- }
2632
- function chunk(items, size) {
2633
- const chunks = [];
2634
- for (let index = 0; index < items.length; index += size) {
2635
- chunks.push(items.slice(index, index + size));
2636
- }
2637
- return chunks;
2638
- }
2639
2863
  function computeLockEntries(params, merged, withheld, generated) {
2640
2864
  const lockEntries = {};
2641
2865
  const sourceBaseKeys = sourcePluralBaseKeys(params.source);
@@ -2783,6 +3007,7 @@ var COLUMN_WIDTHS = {
2783
3007
  [COLUMN.status]: 12,
2784
3008
  [COLUMN.translation]: 50
2785
3009
  };
3010
+ var TEXT_NUMBER_FORMAT = "@";
2786
3011
  function styleHeader(sheet) {
2787
3012
  const header = sheet.getRow(HEADER_ROW);
2788
3013
  HEADERS.forEach((label, index) => {
@@ -2798,6 +3023,7 @@ function applyColumnGeometry(sheet) {
2798
3023
  sheet.getColumn(Number(column)).width = width;
2799
3024
  }
2800
3025
  sheet.getColumn(COLUMN.sourceHash).hidden = true;
3026
+ sheet.getColumn(COLUMN.translation).numFmt = TEXT_NUMBER_FORMAT;
2801
3027
  sheet.views = [{ state: "frozen", ySplit: HEADER_ROW }];
2802
3028
  }
2803
3029
  function writeRow(sheet, sheetRow) {
@@ -2806,7 +3032,9 @@ function writeRow(sheet, sheetRow) {
2806
3032
  row.getCell(COLUMN.source).value = sheetRow.source;
2807
3033
  row.getCell(COLUMN.current).value = sheetRow.currentTarget;
2808
3034
  row.getCell(COLUMN.status).value = sheetRow.status;
2809
- row.getCell(COLUMN.translation).value = sheetRow.translation === "" ? null : sheetRow.translation;
3035
+ const translationCell = row.getCell(COLUMN.translation);
3036
+ translationCell.numFmt = TEXT_NUMBER_FORMAT;
3037
+ translationCell.value = sheetRow.translation === "" ? null : sheetRow.translation;
2810
3038
  row.getCell(COLUMN.sourceHash).value = sheetRow.sourceHash;
2811
3039
  for (let column = COLUMN.key; column <= COLUMN.sourceHash; column += 1) {
2812
3040
  const cell = row.getCell(column);
@@ -3158,9 +3386,20 @@ function judge(row, sourceEntry, adapter) {
3158
3386
  }
3159
3387
  return void 0;
3160
3388
  }
3389
+ function trackBlankDrift(row, params, buckets) {
3390
+ const sourceEntry = params.source.entries.get(row.key);
3391
+ if (sourceEntry === void 0) {
3392
+ return;
3393
+ }
3394
+ const priorHash = params.baseline.get(row.key);
3395
+ if (priorHash !== void 0 && priorHash !== contentHash(sourceEntry)) {
3396
+ buckets.blankDrifted.add(row.key);
3397
+ }
3398
+ }
3161
3399
  function classifyRows(params, buckets) {
3162
3400
  for (const row of params.sheet.rows) {
3163
3401
  if (row.translation === "") {
3402
+ trackBlankDrift(row, params, buckets);
3164
3403
  continue;
3165
3404
  }
3166
3405
  if (isUnknownKey(row, params.source, params.target)) {
@@ -3179,9 +3418,20 @@ function classifyRows(params, buckets) {
3179
3418
  }
3180
3419
  }
3181
3420
  }
3421
+ function blankRowBaselineNotice(count) {
3422
+ return {
3423
+ code: "BLANK_ROW_BASELINE_RETAINED",
3424
+ 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.`
3425
+ };
3426
+ }
3182
3427
  function importLocale(params) {
3183
3428
  const diff2 = diffResources(params.source, params.target, { baseline: params.baseline });
3184
- const buckets = { accepted: /* @__PURE__ */ new Map(), mismatches: [], withheld: /* @__PURE__ */ new Set() };
3429
+ const buckets = {
3430
+ accepted: /* @__PURE__ */ new Map(),
3431
+ mismatches: [],
3432
+ withheld: /* @__PURE__ */ new Set(),
3433
+ blankDrifted: /* @__PURE__ */ new Set()
3434
+ };
3185
3435
  classifyRows(params, buckets);
3186
3436
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
3187
3437
  const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
@@ -3195,9 +3445,11 @@ function importLocale(params) {
3195
3445
  pruned: [],
3196
3446
  invalidIcuSource,
3197
3447
  integrityMismatches: [...buckets.mismatches].sort(),
3448
+ // A workbook import never calls a provider, so a provider-call failure cannot occur here.
3449
+ providerFailures: [],
3198
3450
  // Plural generation is a translate-flow concern; the manual workbook import never generates forms.
3199
3451
  generated: [],
3200
- notices: []
3452
+ notices: buckets.blankDrifted.size > 0 ? [blankRowBaselineNotice(buckets.blankDrifted.size)] : []
3201
3453
  };
3202
3454
  return { summary, accepted: buckets.accepted, withheld: buckets.withheld };
3203
3455
  }
@@ -3231,21 +3483,19 @@ function mergeAccepted(target, accepted) {
3231
3483
  }
3232
3484
  return merged;
3233
3485
  }
3234
- function computeLockEntries2(source, merged, baseline, withheld) {
3486
+ function computeLockEntries2(source, merged, baseline, accepted) {
3235
3487
  const entries = {};
3236
3488
  for (const key of merged.keys()) {
3237
3489
  const sourceEntry = source.entries.get(key);
3238
3490
  if (sourceEntry === void 0) {
3239
3491
  continue;
3240
3492
  }
3241
- if (withheld.has(key)) {
3242
- const prior = baseline.get(key);
3243
- if (prior !== void 0) {
3244
- entries[key] = prior;
3245
- }
3493
+ if (accepted.has(key)) {
3494
+ entries[key] = contentHash(sourceEntry);
3246
3495
  continue;
3247
3496
  }
3248
- entries[key] = contentHash(sourceEntry);
3497
+ const prior = baseline.get(key);
3498
+ entries[key] = prior !== void 0 ? prior : contentHash(sourceEntry);
3249
3499
  }
3250
3500
  return entries;
3251
3501
  }
@@ -3258,7 +3508,7 @@ async function runSheet(ctx, sheet, lock) {
3258
3508
  }
3259
3509
  const target = await readTarget4(ctx.cwd, ctx.config, ctx.adapter, ctx.fs, sheet.locale);
3260
3510
  const baseline = baselineFor(lock, sheet.locale);
3261
- const { summary, accepted, withheld } = importLocale({
3511
+ const { summary, accepted } = importLocale({
3262
3512
  sheet,
3263
3513
  source: ctx.source,
3264
3514
  target,
@@ -3282,7 +3532,7 @@ async function runSheet(ctx, sheet, lock) {
3282
3532
  path
3283
3533
  );
3284
3534
  }
3285
- return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, withheld) };
3535
+ return { summary, lockEntries: computeLockEntries2(ctx.source, merged, baseline, accepted) };
3286
3536
  }
3287
3537
  async function importWorkbook(input, deps = {}) {
3288
3538
  const config = input.config;
@@ -3327,6 +3577,13 @@ async function importWorkbook(input, deps = {}) {
3327
3577
  return { dryRun, locales: summaries, succeeded, failed };
3328
3578
  }
3329
3579
 
3580
+ // src/lock/load-lock-file.ts
3581
+ async function loadLockFile(input = {}, deps = {}) {
3582
+ const cwd = input.cwd ?? process.cwd();
3583
+ const fs = deps.fs ?? defaultFs;
3584
+ return readLockFile(lockFilePath(cwd), fs);
3585
+ }
3586
+
3330
3587
  // src/scaffolding.ts
3331
3588
  var scaffoldingMetadata = {
3332
3589
  /** Provider id -> the environment variable its API key is read from. Owned by ai-providers. */
@@ -3443,6 +3700,7 @@ async function watch(input, deps = {}) {
3443
3700
  }
3444
3701
 
3445
3702
  exports.DEFAULT_WORKBOOK_PATH = DEFAULT_WORKBOOK_PATH;
3703
+ exports.LOCK_FILE_NAME = LOCK_FILE_NAME;
3446
3704
  exports.SdkError = SdkError;
3447
3705
  exports.check = check;
3448
3706
  exports.defineConfig = defineConfig;
@@ -3450,6 +3708,9 @@ exports.diff = diff;
3450
3708
  exports.exportWorkbook = exportWorkbook;
3451
3709
  exports.importWorkbook = importWorkbook;
3452
3710
  exports.loadConfig = loadConfig;
3711
+ exports.loadConfigWithMeta = loadConfigWithMeta;
3712
+ exports.loadLockFile = loadLockFile;
3713
+ exports.lockState = lockState;
3453
3714
  exports.scaffoldingMetadata = scaffoldingMetadata;
3454
3715
  exports.translate = translate2;
3455
3716
  exports.verbatraConfigSchema = verbatraConfigSchema;