@verbatra/sdk 0.6.2 → 0.6.3

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/README.md CHANGED
@@ -44,7 +44,9 @@ const config = await loadConfig();
44
44
  // The provider reads its API key from the environment (e.g. ANTHROPIC_API_KEY). No key is passed.
45
45
  const summary = await translate({ config });
46
46
 
47
- console.log(`${summary.succeeded.length} locale(s) translated, ${summary.failed.length} failed`);
47
+ console.log(
48
+ `${summary.succeeded.length} locale(s) done, ${summary.partial.length} partial, ${summary.failed.length} failed`,
49
+ );
48
50
  ```
49
51
 
50
52
  ## Defining config
@@ -71,21 +73,29 @@ export default defineConfig({
71
73
  });
72
74
  ```
73
75
 
74
- `files.pattern` must contain the `{locale}` token, and `targetLocales` must not include `sourceLocale`; both are enforced when the config is validated. The supported `format` values are `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, `arb`, and `properties`. JSON-family, YAML, and ARB files round-trip in exact document key order: integer-like keys keep their position, new keys append in source-document order, and a YAML composite key (a map or sequence used as a mapping key) fails with a structured error. The optional `glossary` (a term map) and `tone` (`"formal"`, `"informal"`, or `"neutral"`) refine the output. The optional `prune` boolean (off by default) opts in to removing orphaned keys (present in a target file but absent from the source) from the written target files and the lock; the `translate --prune` flag overrides it per run. The optional `generatePlurals` boolean (off by default) opts in to synthesizing the CLDR plural forms a richer target language requires but the source lacks (i18next-JSON projects translated by an LLM provider only; DeepL, non-i18next formats, and unknown languages fall back to the per-locale plural warning and never fail); a per-run `generatePlurals` override on `translate` takes precedence, and generated keys are reported separately from translated keys on the summary. OpenAI and Gemini take `{ model, maxOutputTokens }`; `openai-compatible` additionally takes a `baseUrl` (for a local or self-hosted server such as LM Studio, Ollama, or vLLM) and an optional `apiKeyEnvVar`; DeepL takes `{}` (with an optional `glossaryId`). API keys are never part of the config. Each provider reads its own environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`; `openai-compatible` resolves its key from `apiKeyEnvVar`, then `OPENAI_COMPATIBLE_API_KEY`, then falls back to a keyless placeholder).
76
+ `files.pattern` must contain the `{locale}` token, `targetLocales` must not include `sourceLocale`, and `targetLocales` must not list the same locale twice, compared case-insensitively (two such entries would collide as one Excel worksheet on export); all three are enforced when the config is validated. The supported `format` values are `i18next-json`, `vue-i18n-json`, `next-intl-json`, `ngx-translate-json`, `xliff`, `yaml`, `arb`, and `properties`. JSON-family, YAML, and ARB files round-trip in exact document key order: integer-like keys keep their position, new keys append in source-document order, and a YAML composite key (a map or sequence used as a mapping key) fails with a structured error; a `.properties` write preserves the destination file's existing line endings. The optional `glossary` (a term map, inline or a path to a JSON file of the same shape) and `tone` (`"formal"`, `"informal"`, or `"neutral"`) refine the output. The optional `prune` boolean (off by default) opts in to removing orphaned keys (present in a target file but absent from the source) from the written target files and the lock; the `translate --prune` flag overrides it per run. The optional `generatePlurals` boolean (off by default) opts in to synthesizing the CLDR plural forms a richer target language requires but the source lacks (i18next-JSON projects translated by an LLM provider only; DeepL, non-i18next formats, and unknown languages fall back to the per-locale plural warning and never fail); a per-run `generatePlurals` override on `translate` takes precedence, and generated keys are reported separately from translated keys on the summary. The optional `maxBatchSize` (a positive integer, 50 when absent) caps how many entries go into a single provider request, so a large locale is split into sequential sub-batches and one oversized request cannot sink the whole locale. The optional `maxTokens` sets a whole-run ceiling on input plus output tokens across every provider call, and `budgetBehavior` decides what happens once it is reached: `"warn"` (the default) flags it and lets the run continue, `"stop"` withholds every not-yet-attempted key for the rest of the run so it retries next time. Both are config-only, have no CLI flag, and never change an exit code; against a token-less provider such as DeepL, which reports no usage, the budget stays inert rather than tripping falsely.
77
+
78
+ Anthropic takes `{ model, maxTokens }`; OpenAI and Gemini take `{ model, maxOutputTokens }`; `openai-compatible` takes the same pair plus a `baseUrl` (for a local or self-hosted server such as LM Studio, Ollama, or vLLM) and an optional `apiKeyEnvVar`; DeepL takes `{}` (with an optional `glossaryId`). Every provider additionally accepts an optional `requestTimeoutMs`, a positive-integer per-request timeout in milliseconds that bounds each outbound call. API keys are never part of the config. Each provider reads its own environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`; `openai-compatible` resolves its key from `apiKeyEnvVar`, then `OPENAI_COMPATIBLE_API_KEY`, then falls back to a keyless placeholder).
75
79
 
76
80
  ## API reference
77
81
 
78
82
  ### `defineConfig(config)`
79
83
 
80
- Returns the config unchanged. It exists purely for type inference and editor autocomplete when authoring a code-defined config. The `model` field is restricted to the selected provider's known model IDs (sourced from that provider's own SDK), so a model from another provider is a type error at authoring time; the runtime still validates `model` only as a non-empty string, so an unlisted model runs even though the editor flags it.
84
+ Returns the config unchanged. It exists purely for type inference and editor autocomplete when authoring a code-defined config. For Anthropic, OpenAI, and Gemini the `model` field is restricted to that provider's known model IDs (sourced from its own SDK), so a model from another provider is a type error at authoring time; the runtime still validates `model` only as a non-empty string, so an unlisted model runs even though the editor flags it. DeepL has no `model` field, and `openai-compatible`'s model is whatever the local server exposes, so neither is restricted.
81
85
 
82
86
  ### `loadConfig(options?): Promise<VerbatraConfig>`
83
87
 
84
- Discovers and validates the configuration. With no arguments it searches upward from the current working directory; `options` accepts `cwd`, an explicit `configPath`, or an in-memory `configOverride`. Resolves to the validated `VerbatraConfig`, and throws an `SdkError` if no config is found or it fails validation.
88
+ Discovers and validates the configuration. With no arguments it searches upward from the current working directory; `options` accepts `cwd`, an explicit `configPath`, an in-memory `configOverride`, and an `fs` seam. Precedence is `configOverride`, then `configPath`, then the search. Resolves to the validated `VerbatraConfig`, and throws an `SdkError` if no config is found (`CONFIG_NOT_FOUND`) or it fails validation (`CONFIG_INVALID`). A `glossary` given as a file path is read and validated here, so every downstream call receives a plain term map.
85
89
 
86
90
  ### `translate(input): Promise<RunSummary>`
87
91
 
88
- Runs the one-shot read, diff, translate, write flow over every target locale. `input` is `{ config, cwd?, dryRun?, prune?, generatePlurals? }`. With `dryRun: true` it reads, diffs, and reports without calling the provider or writing anything. `prune` and `generatePlurals` each override the matching config option for this run. Resolves to a `RunSummary` (`dryRun`, `locales`, `succeeded`, and `failed`); each locale summary lists `translated`, `generated`, `pruned`, `integrityMismatches`, `providerFailures`, and `notices`. `integrityMismatches` is a translation that came back and failed the placeholder-integrity check; `providerFailures` is a key withheld because the provider call itself failed (nothing was translated), with the secret-free failure code and message reported in `notices`.
92
+ Runs the one-shot read, diff, translate, write flow over every target locale. `input` is a `TranslateInput`: `{ config, cwd?, dryRun?, prune?, generatePlurals?, cache?, concurrency?, lockAcquireTimeoutMs?, onProgress?, onLockWait? }`. With `dryRun: true` it reads, diffs, and reports without calling the provider or writing anything. `prune` and `generatePlurals` each override the matching config option for this run.
93
+
94
+ `cache` (on by default, and ignored on a dry run) toggles the local content-addressed translation-memory cache in `verbatra.cache.json`: a key whose source content is unchanged, including under a renamed key or shared with another key, is served from the cache instead of being re-sent to the provider. A cached value is re-checked against that key's own current source first, and one that fails the gate falls through to the provider rather than being written. `concurrency` (defaults to 1, strictly serial) is how many target locales may run at once; it must be an integer of at least 1, and on a live run a value above 1 is refused when the config sets `maxTokens`, because concurrent locales would overshoot the budget nondeterministically. `lockAcquireTimeoutMs` overrides how long a locale's write lock keeps retrying before it fails. `onProgress` receives a structured event once per locale before and after it runs, once per provider sub-batch, and once when the locale loop ends; `onLockWait` fires while a locale's write lock is blocked on another process holding it. The SDK writes to no stream, so these callbacks are the only progress signal.
95
+
96
+ Resolves to a `RunSummary`: `dryRun`, `locales`, `succeeded`, `partial`, `failed`, plus `usage` when any provider call reported tokens and `budget` when `maxTokens` is configured. A locale's `status` is `"succeeded"` when nothing was withheld (a no-op with no candidate keys included), `"partial"` when it accepted at least one key and withheld at least one, and `"failed"` when it withheld keys and accepted none, or threw. Each `LocaleSummary` carries `locale`, `status`, `translated`, `unchanged`, `orphaned`, `pruned`, `invalidIcuSource`, `cacheHits`, `integrityMismatches`, `providerFailures`, `budgetWithheld`, `generated`, `notices`, `needsReview`, `unfilled`, `malformedRows`, `duplicateKeys`, an optional `usage`, and an optional `error` on a locale that threw. `cacheHits` are keys served from the translation-memory cache rather than the provider. `integrityMismatches` is a translation that came back and was withheld by verbatra's integrity gate, whose rejection reasons are the exported `IntegrityGateReason` union. `providerFailures` is a key withheld because nothing was translated for it (the provider call failed, or the response was still missing that key), with any secret-free failure code and message reported in `notices`. `budgetWithheld` is a candidate never sent because a `maxTokens` budget in `"stop"` mode had already tripped. `needsReview` flags accepted keys the review heuristics want a human to look at, and never withholds anything. `unfilled`, `malformedRows`, and `duplicateKeys` are populated only by `importWorkbook`, which returns this same shape. Every withheld key keeps its prior lock hash and is retried next run.
97
+
98
+ Whole-run failures throw an `SdkError`: an unknown format, provider construction (including a missing API key), an unreadable or invalid source file, a corrupt lock file, an invalid `concurrency`, or the concurrency-and-budget conflict above. A per-locale failure never throws; it is isolated on that locale's summary. On a non-dry run the flow also writes `.verbatra-local/run-status.json` (best-effort, read back through `runStatus`).
89
99
 
90
100
  ```ts
91
101
  const preview = await translate({ config, dryRun: true });
@@ -93,7 +103,9 @@ const preview = await translate({ config, dryRun: true });
93
103
 
94
104
  ### `watch(input): Promise<WatchController>`
95
105
 
96
- Watches the source file and re-runs the translate flow on each debounced change. `input` is `{ config, cwd?, debounceMs?, onRun }`, where `onRun` receives a `WatchRunResult` per run. Resolves to a `WatchController` whose `stop()` closes the watcher and awaits the in-flight run.
106
+ Watches the source file and re-runs the translate flow on each debounced change. `input` is `{ config, cwd?, debounceMs?, onRun, cache?, concurrency?, lockAcquireTimeoutMs?, onLockWait?, onProgress? }`; `debounceMs` defaults to 300, and the last five are passed straight through to every run. One run starts immediately at startup, before any change arrives. Runs are serialized, so changes during a run collapse into a single follow-up.
107
+
108
+ `onRun` receives a `WatchRunResult` per run: `{ status: "succeeded", summary }` or `{ status: "failed", error }` with a secret-free `{ code, message }`, so a failing run is reported and watching continues. `watch` itself throws only at startup: `CONCURRENCY_INVALID` or `CONCURRENCY_BUDGET_CONFLICT` for a `concurrency` no cycle could honor (resolved once, before the watcher exists, rather than failing every cycle), and `SOURCE_UNREADABLE` when the source locale file is absent. Resolves to a `WatchController` whose `stop()` closes the watcher and awaits the in-flight run.
97
109
 
98
110
  ```ts
99
111
  import { loadConfig, watch } from "@verbatra/sdk";
@@ -125,7 +137,7 @@ if (!summary.inSync) {
125
137
 
126
138
  ### `diff(input): Promise<DiffSummary>`
127
139
 
128
- Lists the keys a run would touch, without writing anything. `input` is the same `{ config, cwd?, locales? }` shape as `check`. Resolves to a `DiffSummary` whose `locales` lists one `LocaleDiff` each, with the key arrays `missing` (would be added), `changed` (would be re-translated), and `orphaned` (present in the target but absent from the source).
140
+ Lists the keys a run would touch, without writing anything. `input` is the same `{ config, cwd?, locales? }` shape as `check`. Resolves to a `DiffSummary` whose `locales` lists one `LocaleDiff` each, with the key arrays `missing` (would be added), `changed` (would be re-translated), and `orphaned` (present in the target but absent from the source), plus a per-locale `hasPendingChanges` driven by `missing` and `changed` only, since a default run does not prune. The top-level `hasPendingChanges` is true when any checked locale has some.
129
141
 
130
142
  ```ts
131
143
  import { diff, loadConfig } from "@verbatra/sdk";
@@ -136,11 +148,11 @@ const summary = await diff({ config });
136
148
 
137
149
  ### `exportWorkbook(input): Promise<ExportWorkbookResult>`
138
150
 
139
- Exports the strings that need translating into a styled Excel workbook for a human translator. `input` is `{ config, cwd?, out?, locales?, includeUnchanged? }`. By default it writes the missing and changed strings for every target locale to `verbatra-translations.xlsx`; `out` overrides the path, `locales` narrows which target locales are exported, and `includeUnchanged: true` also exports already up-to-date strings. Resolves to an `ExportWorkbookResult` with the written `path` and a per-locale row count.
151
+ Exports the strings that need translating into a styled Excel workbook for a human translator. `input` is `{ config, cwd?, out?, locales?, includeUnchanged? }`. By default it writes the missing and changed strings for every target locale to `verbatra-translations.xlsx`; `out` overrides the path, `locales` narrows which target locales are exported, and `includeUnchanged: true` also exports already up-to-date strings. No provider is called and no lock file is written. Resolves to an `ExportWorkbookResult` with the absolute `path` written and a per-locale row count.
140
152
 
141
153
  ### `importWorkbook(input): Promise<RunSummary>`
142
154
 
143
- Imports a filled workbook back into the locale files, running the same placeholder and ICU checks as `translate`. `input` is `{ config, workbook, cwd?, dryRun? }`. With `dryRun: true` it validates and reports without writing locale files or updating the lock. Resolves to a `RunSummary`, the same shape `translate` returns.
155
+ Imports a filled workbook back into the locale files, gating every row through the same integrity gate as `translate` on top of a fresh source-drift check. `input` is `{ config, workbook, cwd?, dryRun? }`. With `dryRun: true` it validates and reports without writing locale files or updating the lock. Resolves to a `RunSummary`, the same shape `translate` returns: a row the translator left blank whose key still needs a translation is reported in that locale's `unfilled` (nothing is written and the prior lock baseline is kept), an unreadable row in `malformedRows`, a repeated key in `duplicateKeys` (the first occurrence wins), and a configured target locale whose sheet is missing from the workbook is reported as that locale's failed summary rather than silently dropped. `unfilled`, `malformedRows`, and `duplicateKeys` do not feed a locale's `status`, so a partly filled sheet still imports the rows it has. A key is cleared by filling its Translation cell with the `[[CLEAR]]` sentinel; an ordinary blank never clears a value.
144
156
 
145
157
  ```ts
146
158
  import { exportWorkbook, importWorkbook, loadConfig } from "@verbatra/sdk";
@@ -160,17 +172,19 @@ See [Manual translation](https://verbatra.kreitz-webdev.de/docs/manual-translati
160
172
 
161
173
  Beyond the flows above, the SDK exports the building blocks Verbatra Studio and other tooling sit on, each a one-call read or a locked single-key write:
162
174
 
163
- - `keyIntegrity` reports, per changed key, whether its placeholders and ICU structure still match the source.
175
+ - `keyIntegrity` reports, per changed key, whether its placeholders still match the source (with the missing and extra tokens on a mismatch) and whether the current target value is still valid ICU.
164
176
  - `lockState` reports the lock file's existence, version, and per-locale drift; `loadLockFile` reads the lock file itself.
165
- - `runStatus` reads the persisted review-flag and token-usage snapshot the last `translate` or `watch` run left behind.
177
+ - `runStatus` reads the persisted review-flag and token-usage snapshot the last non-dry `translate` or `watch` run left behind; it never throws, and a missing, corrupt, or unrecognized file simply reports as unavailable.
166
178
  - `keyValue` reads one key's current source and target values.
167
- - `editEntry` saves a manually edited translation for one key, and `retranslateEntry` re-runs the provider for one key; both run the same placeholder and ICU integrity checks as a full run and hold the same per-locale write lock.
179
+ - `editEntry` saves a manually edited translation for one key, and `retranslateEntry` re-runs the provider for one key; both run the candidate through the same integrity gate as a full run (a rejection names an `IntegrityGateReason` and writes nothing) and hold the same per-locale write lock.
168
180
  - `readLocaleFileSnapshot` and `diffLocaleSnapshots` snapshot one locale file as per-key content hashes and compare two snapshots, the primitives behind live-refresh watching.
169
181
  - `loadConfigWithMeta` is `loadConfig` plus config-source and glossary provenance.
170
182
 
171
183
  ## Errors and results
172
184
 
173
- The SDK throws a single structured error type, `SdkError`, for whole-run failures such as a missing or invalid config or an unreadable source file. It carries a stable `code` and never contains an API key. Per-locale failures do not throw: they are recorded on the `RunSummary` so one failing locale never aborts the others.
185
+ `SdkError` is the SDK's own structured error type, thrown for whole-run failures such as a missing or invalid config or an unreadable source file. It carries a stable `code` from the exported `SdkErrorCode` union and never contains an API key. It is not the only error a caller can see: `retranslateEntry` propagates the provider's own `ProviderError` when the provider call fails or returns nothing for the key, and a target locale file that exists but is malformed rejects with the adapter's own error.
186
+
187
+ Per-locale failures do not throw: they are recorded on the `RunSummary` so one failing locale never aborts the others, and that includes a locale whose write lock could not be acquired. A locale's `error.code` is a preserved string from the underlying provider or adapter failure (`"LOCALE_FAILED"` is only the fallback), deliberately wider than `SdkErrorCode`, so do not treat it as a closed set.
174
188
 
175
189
  ## Documentation
176
190
 
package/dist/index.cjs CHANGED
@@ -61,27 +61,31 @@ var translationMemorySchema = zod.z.object({
61
61
  function cacheFilePath(cwd) {
62
62
  return path.resolve(cwd, CACHE_FILE_NAME);
63
63
  }
64
+ var UNUSABLE = { memory: EMPTY_MEMORY, writable: true };
64
65
  async function readTranslationMemory(path, fs) {
65
66
  let read;
66
67
  try {
67
68
  read = await fs.readFileBounded(path, MAX_CACHE_FILE_BYTES);
68
69
  } catch {
69
- return EMPTY_MEMORY;
70
+ return UNUSABLE;
70
71
  }
71
72
  if (read.kind !== "ok") {
72
- return EMPTY_MEMORY;
73
+ return UNUSABLE;
73
74
  }
74
75
  let parsed;
75
76
  try {
76
77
  parsed = JSON.parse(read.content);
77
78
  } catch {
78
- return EMPTY_MEMORY;
79
+ return UNUSABLE;
79
80
  }
80
81
  const result = translationMemorySchema.safeParse(parsed);
81
- if (!result.success || result.data.version !== CURRENT_VERSION) {
82
- return EMPTY_MEMORY;
82
+ if (!result.success) {
83
+ return UNUSABLE;
83
84
  }
84
- return result.data;
85
+ if (result.data.version !== CURRENT_VERSION) {
86
+ return { memory: EMPTY_MEMORY, writable: false };
87
+ }
88
+ return { memory: result.data, writable: true };
85
89
  }
86
90
  function lookupMemory(memory, fingerprint, locale, contentHash2) {
87
91
  return memory.entries[fingerprint]?.[locale]?.[contentHash2];
@@ -133,7 +137,10 @@ async function feedTranslationMemory(cwd, fs, fingerprint, additionsByLocale) {
133
137
  }
134
138
  try {
135
139
  const path = cacheFilePath(cwd);
136
- const memory = await readTranslationMemory(path, fs);
140
+ const { memory, writable } = await readTranslationMemory(path, fs);
141
+ if (!writable) {
142
+ return;
143
+ }
137
144
  await writeTranslationMemory(path, applyAdditions(memory, fingerprint, additionsByLocale), fs);
138
145
  } catch {
139
146
  }
@@ -3339,6 +3346,12 @@ function extractPropertiesPlaceholders(value) {
3339
3346
  var UNICODE_ESCAPE = /^[0-9a-fA-F]{4}$/;
3340
3347
  var LEADING_WHITESPACE = /^[ \t\f]+/;
3341
3348
  var TRAILING_TERMINATOR = /(?:\r\n|\r|\n)$/;
3349
+ function detectLineTerminator(content) {
3350
+ if (content.includes("\r\n")) {
3351
+ return "\r\n";
3352
+ }
3353
+ return content.includes("\r") ? "\r" : "\n";
3354
+ }
3342
3355
  function isPropertiesWhitespace(char) {
3343
3356
  return char === " " || char === " " || char === "\f";
3344
3357
  }
@@ -3544,7 +3557,7 @@ async function readStructure(filePath) {
3544
3557
  outcome = await readBounded2(filePath);
3545
3558
  } catch (error) {
3546
3559
  if (isFileNotFound(error)) {
3547
- return [];
3560
+ return { items: [], terminator: "\n" };
3548
3561
  }
3549
3562
  throw new AdapterError("INVALID_STRUCTURE", "The destination file could not be read.");
3550
3563
  }
@@ -3554,13 +3567,16 @@ async function readStructure(filePath) {
3554
3567
  if (outcome.kind === "too-large") {
3555
3568
  throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
3556
3569
  }
3557
- return parseItems(outcome.content);
3570
+ return {
3571
+ items: parseItems(outcome.content),
3572
+ terminator: detectLineTerminator(outcome.content)
3573
+ };
3558
3574
  }
3559
3575
  async function serializePropertiesEntries(entries, filePath) {
3560
- const structure = await readStructure(filePath);
3576
+ const { items, terminator } = await readStructure(filePath);
3561
3577
  const emitted = /* @__PURE__ */ new Set();
3562
3578
  const lines = [];
3563
- for (const item of structure) {
3579
+ for (const item of items) {
3564
3580
  if (item.kind === "raw") {
3565
3581
  lines.push(item.text);
3566
3582
  continue;
@@ -3576,8 +3592,7 @@ async function serializePropertiesEntries(entries, filePath) {
3576
3592
  lines.push(formatEntry(key, entry.value));
3577
3593
  }
3578
3594
  }
3579
- return lines.length === 0 ? "" : `${lines.join("\n")}
3580
- `;
3595
+ return lines.length === 0 ? "" : `${lines.join(terminator)}${terminator}`;
3581
3596
  }
3582
3597
  function createPropertiesAdapter() {
3583
3598
  return createFlatFileAdapter({
@@ -4130,6 +4145,9 @@ function gateCandidateValue(sourceEntry, candidateValue, adapter) {
4130
4145
  if (assessValueDegeneracy(sourceEntry.value, candidateValue).degenerate) {
4131
4146
  return { accepted: false, reason: "degenerate" };
4132
4147
  }
4148
+ if (sourceEntry.value.trim() !== "" && candidateValue.trim() === "") {
4149
+ return { accepted: false, reason: "empty" };
4150
+ }
4133
4151
  return { accepted: true };
4134
4152
  }
4135
4153
 
@@ -4979,6 +4997,10 @@ function fanOutAccepted(params, group, acceptedRepresentative, outcome) {
4979
4997
  if (source === void 0) {
4980
4998
  continue;
4981
4999
  }
5000
+ if (!gateCandidateValue(source, acceptedRepresentative.value, params.adapter).accepted) {
5001
+ outcome.integrityMismatches.push(key);
5002
+ continue;
5003
+ }
4982
5004
  outcome.accepted.set(key, { value: acceptedRepresentative.value, source });
4983
5005
  if (flag !== void 0) {
4984
5006
  outcome.reviewFlags.set(key, flag);
@@ -5370,14 +5392,21 @@ async function createRunCacheState(input, config, cwd, dryRun, fs) {
5370
5392
  if (dryRun || input.cache === false) {
5371
5393
  return void 0;
5372
5394
  }
5373
- return {
5374
- memory: await readTranslationMemory(cacheFilePath(cwd), fs),
5375
- fingerprint: computeFingerprint(config),
5376
- additions: /* @__PURE__ */ new Map()
5395
+ const { memory, writable } = await readTranslationMemory(cacheFilePath(cwd), fs);
5396
+ return { memory, writable, fingerprint: computeFingerprint(config), additions: /* @__PURE__ */ new Map() };
5397
+ }
5398
+ function withCacheNotices(summaries, cache) {
5399
+ if (cache === void 0 || cache.writable) {
5400
+ return [...summaries];
5401
+ }
5402
+ const notice = {
5403
+ code: "CACHE_VERSION_UNRECOGNIZED",
5404
+ message: `${CACHE_FILE_NAME} carries a version this build does not recognize, so it was written by a newer verbatra. It was left untouched and this run used no cache. Upgrade verbatra, or delete the file to rebuild it in this build's format.`
5377
5405
  };
5406
+ return summaries.map((summary) => ({ ...summary, notices: [...summary.notices, notice] }));
5378
5407
  }
5379
5408
  async function recordCacheAdditions(cwd, cache, fs) {
5380
- if (cache === void 0 || cache.additions.size === 0) {
5409
+ if (cache === void 0 || cache.additions.size === 0 || !cache.writable) {
5381
5410
  return;
5382
5411
  }
5383
5412
  try {
@@ -5464,18 +5493,25 @@ async function runLocaleAt(context, targetLocales, localeIndex, runOne, results)
5464
5493
  context.onProgress?.({
5465
5494
  type: "locale-finished",
5466
5495
  locale: targetLocale,
5467
- translated: summary.translated.length
5496
+ translated: summary.translated.length,
5497
+ localeIndex,
5498
+ totalLocales: targetLocales.length
5468
5499
  });
5469
5500
  }
5470
5501
  async function runLocalesWithProgress(context, targetLocales, runOne, concurrency) {
5471
5502
  const totalLocales = targetLocales.length;
5472
5503
  const results = new Array(totalLocales);
5473
5504
  let nextIndex = 0;
5505
+ let abort;
5474
5506
  async function worker() {
5475
- while (nextIndex < totalLocales) {
5507
+ while (abort === void 0 && nextIndex < totalLocales) {
5476
5508
  const localeIndex = nextIndex;
5477
5509
  nextIndex += 1;
5478
- await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5510
+ try {
5511
+ await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5512
+ } catch (error) {
5513
+ abort ??= { reason: error };
5514
+ }
5479
5515
  }
5480
5516
  }
5481
5517
  const workerCount = Math.min(concurrency, totalLocales);
@@ -5484,6 +5520,9 @@ async function runLocalesWithProgress(context, targetLocales, runOne, concurrenc
5484
5520
  workers.push(worker());
5485
5521
  }
5486
5522
  await Promise.all(workers);
5523
+ if (abort !== void 0) {
5524
+ throw abort.reason;
5525
+ }
5487
5526
  return results.filter((summary) => summary !== void 0);
5488
5527
  }
5489
5528
  async function runAllLocalesDry(context, targetLocales, concurrency) {
@@ -5560,7 +5599,8 @@ async function translate2(input, deps = {}) {
5560
5599
  };
5561
5600
  const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales, concurrency) : await runAllLocalesLive(context, config.targetLocales, concurrency);
5562
5601
  input.onProgress?.({ type: "run-finished", localesCompleted: summaries.length });
5563
- const { succeeded, partial, failed } = partition(summaries);
5602
+ const locales = withCacheNotices(summaries, cache);
5603
+ const { succeeded, partial, failed } = partition(locales);
5564
5604
  const usage = summaries.reduce(
5565
5605
  (total, summary2) => combineUsage(total, summary2.usage),
5566
5606
  void 0
@@ -5568,7 +5608,7 @@ async function translate2(input, deps = {}) {
5568
5608
  const budgetSummary = toBudgetSummary(budget);
5569
5609
  const summary = {
5570
5610
  dryRun,
5571
- locales: summaries,
5611
+ locales,
5572
5612
  succeeded,
5573
5613
  partial,
5574
5614
  failed,
@@ -6140,12 +6180,12 @@ function classifyClear(row, sourceEntry, buckets) {
6140
6180
  buckets.withheld.add(row.key);
6141
6181
  return;
6142
6182
  }
6143
- buckets.accepted.set(row.key, { value: "", source: sourceEntry });
6183
+ buckets.accepted.set(row.key, { value: "", source: sourceEntry, cleared: true });
6144
6184
  }
6145
- function classifyRows(params, buckets) {
6185
+ function classifyRows(params, buckets, liveCandidates) {
6146
6186
  for (const row of params.sheet.rows) {
6147
6187
  if (row.translation === "") {
6148
- if (row.status === "changed") {
6188
+ if (liveCandidates.has(row.key)) {
6149
6189
  buckets.unfilled.push(row.key);
6150
6190
  }
6151
6191
  trackBlankDrift(row, params, buckets);
@@ -6164,7 +6204,11 @@ function classifyRows(params, buckets) {
6164
6204
  }
6165
6205
  const reason = judge(row, sourceEntry, params.adapter);
6166
6206
  if (reason === void 0) {
6167
- buckets.accepted.set(row.key, { value: row.translation, source: sourceEntry });
6207
+ buckets.accepted.set(row.key, {
6208
+ value: row.translation,
6209
+ source: sourceEntry,
6210
+ cleared: false
6211
+ });
6168
6212
  } else {
6169
6213
  buckets.mismatches.push(row.key);
6170
6214
  buckets.withheld.add(row.key);
@@ -6186,7 +6230,7 @@ function importLocale(params) {
6186
6230
  blankDrifted: /* @__PURE__ */ new Set(),
6187
6231
  unfilled: []
6188
6232
  };
6189
- classifyRows(params, buckets);
6233
+ classifyRows(params, buckets, /* @__PURE__ */ new Set([...diff2.missing, ...diff2.changed]));
6190
6234
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
6191
6235
  const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
6192
6236
  const translated = [...buckets.accepted.keys()].sort();
@@ -6244,8 +6288,10 @@ function mergeAccepted(target, accepted) {
6244
6288
  }
6245
6289
  function sheetCacheAdditions(accepted) {
6246
6290
  const record = {};
6247
- for (const [, { value, source }] of accepted) {
6248
- record[contentHash(source)] = value;
6291
+ for (const [, { value, source, cleared }] of accepted) {
6292
+ if (!cleared) {
6293
+ record[contentHash(source)] = value;
6294
+ }
6249
6295
  }
6250
6296
  return record;
6251
6297
  }
@@ -6431,6 +6477,7 @@ async function watch(input, deps = {}) {
6431
6477
  const cwd = input.cwd ?? process.cwd();
6432
6478
  const debounceMs = input.debounceMs ?? DEFAULT_DEBOUNCE_MS;
6433
6479
  const fs = deps.fs ?? defaultFs;
6480
+ resolveRunConcurrency(input.concurrency, false, input.config);
6434
6481
  const sourcePath = localeFilePath(cwd, input.config.files.pattern, input.config.sourceLocale);
6435
6482
  if (!await fs.fileExists(sourcePath)) {
6436
6483
  throw new SdkError(