@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/dist/index.d.cts CHANGED
@@ -972,7 +972,7 @@ interface DiffDeps {
972
972
  declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
973
973
 
974
974
  /** Why {@link gateCandidateValue} rejected a candidate value. */
975
- type IntegrityGateReason = "placeholder" | "icu" | "degenerate";
975
+ type IntegrityGateReason = "placeholder" | "icu" | "degenerate" | "empty";
976
976
 
977
977
  /** Input for {@link editEntry}: the validated config, exactly one target locale/key pair, and the human-typed replacement value. */
978
978
  interface EditEntryInput {
@@ -1348,7 +1348,7 @@ type RetranslateEntryResult = {
1348
1348
  declare function retranslateEntry(input: RetranslateEntryInput, deps?: RetranslateEntryDeps): Promise<RetranslateEntryResult>;
1349
1349
 
1350
1350
  /** A stable code for an SDK-originated notice (not a provider notice). */
1351
- type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED";
1351
+ type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED" | "CACHE_VERSION_UNRECOGNIZED";
1352
1352
  /** Summed token usage across every provider call in a scope (one locale, or the whole run). */
1353
1353
  interface UsageSummary {
1354
1354
  readonly inputTokens: number;
@@ -1432,7 +1432,27 @@ interface LocaleSummary {
1432
1432
  * {@link LocaleSummary.malformedRows}, and {@link LocaleSummary.duplicateKeys}, deliberately do NOT
1433
1433
  * feed this status: they are surfaced in their own lists (and the CLI) but are not withholdings of
1434
1434
  * an attempted translation, so a locale that dropped a malformed row, collapsed a duplicate key, or
1435
- * left a `changed` row unfilled while accepting its other rows can still be `"succeeded"`.
1435
+ * left a row unfilled while accepting its other rows can still be `"succeeded"`.
1436
+ *
1437
+ * This is a settled decision, not an omission. Recording the reasoning here so the question stops
1438
+ * recurring:
1439
+ *
1440
+ * 1. Two questions, two commands. `import`'s exit code answers "did the import apply what the
1441
+ * translator gave me". `check` and `diff` answer "is this project fully translated", and both
1442
+ * already exit non-zero or report pending in every one of these cases. Folding pending human
1443
+ * work into the import exit code duplicates an existing gate rather than adding one.
1444
+ * 2. It would break the staged handoff. With `de` and `fr` configured and only `de` filled in,
1445
+ * failing on unfilled work makes `fr` fail and takes the whole import with it, even though `de`
1446
+ * imported perfectly. Locale-at-a-time handoff is this feature's most common workflow.
1447
+ * 3. {@link LocaleSummary.malformedRows} structurally cannot signal lost work. The Status column is
1448
+ * the only reachable rejection in the workbook reader; every other field is an unconstrained
1449
+ * string or a tolerant parse. So a row is malformed on its Status cell alone, entirely
1450
+ * independently of whether its Translation cell was filled. An untouched sheet with a mangled
1451
+ * Status column (spreadsheet autocorrect, a CAT-tool round trip) is all-malformed with zero lost
1452
+ * work. A bucket that cannot distinguish dropped work from absent work cannot honestly drive a
1453
+ * failure status.
1454
+ * 4. {@link LocaleSummary.duplicateKeys} never could: the first occurrence wins and the intent is
1455
+ * honoured.
1436
1456
  */
1437
1457
  readonly status: "succeeded" | "partial" | "failed";
1438
1458
  /**
@@ -1513,11 +1533,16 @@ interface LocaleSummary {
1513
1533
  */
1514
1534
  readonly needsReview: readonly NeedsReviewEntry[];
1515
1535
  /**
1516
- * Keys the workbook exported as `changed` (needing an updated translation) that the translator left
1517
- * blank, drifted or not, sorted by key. Pending work made visible: the row is skipped (nothing is
1518
- * written and the prior lock baseline is kept), but the key is surfaced so an unfinished import is
1519
- * never silently reported as done. Only a workbook import populates this; the provider path, which
1520
- * never leaves a candidate key unfilled by a person, always leaves it empty.
1536
+ * Keys the translator left blank that still need a translation, drifted or not, sorted by key.
1537
+ * Pending work made visible: the row is skipped (nothing is written and the prior lock baseline is
1538
+ * kept), but the key is surfaced so an unfinished import is never silently reported as done. Only a
1539
+ * workbook import populates this; the provider path, which never leaves a candidate key unfilled by
1540
+ * a person, always leaves it empty.
1541
+ *
1542
+ * Membership is decided by the import-time diff, not by the `status` the row was exported with: a
1543
+ * never-translated key exports as `new`, and a first handoff is all-new, which is the most common
1544
+ * unfilled case there is. A row exported as `changed` whose key no longer needs work by import time
1545
+ * is correspondingly excluded.
1521
1546
  */
1522
1547
  readonly unfilled: readonly string[];
1523
1548
  /**
@@ -1697,6 +1722,19 @@ interface LocaleFinishedEvent {
1697
1722
  readonly locale: string;
1698
1723
  /** Number of keys accepted (translated) for this locale. In a dry-run, the keys that would be translated. */
1699
1724
  readonly translated: number;
1725
+ /**
1726
+ * Zero-based index of this locale in the run's target-locale order, carrying the same value as the
1727
+ * matching {@link LocaleStartedEvent}. This is a correlation key, so a consumer can pair a finish
1728
+ * with its start without matching on the locale name.
1729
+ *
1730
+ * It is deliberately NOT a progress counter. The worker pool claims indices up front, so this is a
1731
+ * claim ordinal rather than a completion ordinal: under concurrency the locales finish in a
1732
+ * different order, and rendering `localeIndex + 1` of `totalLocales` on a finish produces a
1733
+ * counter that jumps around and never reaches its own total in order. Do not reintroduce one.
1734
+ */
1735
+ readonly localeIndex: number;
1736
+ /** Total number of target locales in this run, mirroring {@link LocaleStartedEvent}. */
1737
+ readonly totalLocales: number;
1700
1738
  }
1701
1739
  /** The run reached the end of its locale loop: how many locales it processed. */
1702
1740
  interface RunFinishedEvent {
@@ -2060,8 +2098,9 @@ interface WatchInput {
2060
2098
  readonly lockAcquireTimeoutMs?: number;
2061
2099
  /**
2062
2100
  * Passed through to every run's {@link TranslateInput.concurrency}: how many locales each cycle may
2063
- * run at once. Defaults to 1 (strictly serial). The per-cycle tracker is fresh, so a budgeted watch
2064
- * with concurrency greater than 1 still fails each run with `CONCURRENCY_BUDGET_CONFLICT`.
2101
+ * run at once. Defaults to 1 (strictly serial). Because the per-cycle budget tracker is fresh, a
2102
+ * value greater than 1 cannot be honored alongside a `maxTokens` budget; {@link watch} refuses that
2103
+ * combination at startup rather than letting every cycle fail on it.
2065
2104
  */
2066
2105
  readonly concurrency?: number;
2067
2106
  /**
@@ -2095,9 +2134,23 @@ interface WatchController {
2095
2134
  * `onRun` as a {@link WatchRunResult} and watching continues. Returns a controller whose `stop()`
2096
2135
  * closes the watcher and awaits the in-flight run.
2097
2136
  *
2137
+ * Both startup refusals are ordered deliberately: `concurrency` is resolved first because it is
2138
+ * decidable from the arguments alone, while the missing-source check is an I/O probe of project
2139
+ * state, so argument validation runs before any I/O and before the watcher exists. The concurrency
2140
+ * resolve passes `dryRun: false` unconditionally, which is exact rather than a simplification: there
2141
+ * is no dry-run watch, so the budget conflict always applies to a session.
2142
+ *
2143
+ * Resolving it here rather than leaving it to each cycle is the point, not an optimization: a watch
2144
+ * session is long-lived, so a per-cycle refusal would fail the initial run and every run after it,
2145
+ * indefinitely, while the session stayed alive and looked healthy.
2146
+ *
2098
2147
  * @param input - The config, optional cwd/debounce, and the `onRun` callback that receives each result.
2099
2148
  * @param deps - Optional composition seams (watcher, run, registry, provider builder, file system) for tests.
2100
2149
  * @returns A {@link WatchController}; call `stop()` to close the watcher and await the in-flight run.
2150
+ * @throws {@link SdkError} `CONCURRENCY_INVALID`: at startup only, when `concurrency` is defined but
2151
+ * not an integer of at least 1.
2152
+ * @throws {@link SdkError} `CONCURRENCY_BUDGET_CONFLICT`: at startup only, when `concurrency` is
2153
+ * greater than 1 while the config sets a `maxTokens` budget.
2101
2154
  * @throws {@link SdkError} `SOURCE_UNREADABLE`: at startup only, when the source locale file is absent.
2102
2155
  * @example
2103
2156
  * ```ts
package/dist/index.d.ts CHANGED
@@ -972,7 +972,7 @@ interface DiffDeps {
972
972
  declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
973
973
 
974
974
  /** Why {@link gateCandidateValue} rejected a candidate value. */
975
- type IntegrityGateReason = "placeholder" | "icu" | "degenerate";
975
+ type IntegrityGateReason = "placeholder" | "icu" | "degenerate" | "empty";
976
976
 
977
977
  /** Input for {@link editEntry}: the validated config, exactly one target locale/key pair, and the human-typed replacement value. */
978
978
  interface EditEntryInput {
@@ -1348,7 +1348,7 @@ type RetranslateEntryResult = {
1348
1348
  declare function retranslateEntry(input: RetranslateEntryInput, deps?: RetranslateEntryDeps): Promise<RetranslateEntryResult>;
1349
1349
 
1350
1350
  /** A stable code for an SDK-originated notice (not a provider notice). */
1351
- type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED";
1351
+ type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED" | "CACHE_VERSION_UNRECOGNIZED";
1352
1352
  /** Summed token usage across every provider call in a scope (one locale, or the whole run). */
1353
1353
  interface UsageSummary {
1354
1354
  readonly inputTokens: number;
@@ -1432,7 +1432,27 @@ interface LocaleSummary {
1432
1432
  * {@link LocaleSummary.malformedRows}, and {@link LocaleSummary.duplicateKeys}, deliberately do NOT
1433
1433
  * feed this status: they are surfaced in their own lists (and the CLI) but are not withholdings of
1434
1434
  * an attempted translation, so a locale that dropped a malformed row, collapsed a duplicate key, or
1435
- * left a `changed` row unfilled while accepting its other rows can still be `"succeeded"`.
1435
+ * left a row unfilled while accepting its other rows can still be `"succeeded"`.
1436
+ *
1437
+ * This is a settled decision, not an omission. Recording the reasoning here so the question stops
1438
+ * recurring:
1439
+ *
1440
+ * 1. Two questions, two commands. `import`'s exit code answers "did the import apply what the
1441
+ * translator gave me". `check` and `diff` answer "is this project fully translated", and both
1442
+ * already exit non-zero or report pending in every one of these cases. Folding pending human
1443
+ * work into the import exit code duplicates an existing gate rather than adding one.
1444
+ * 2. It would break the staged handoff. With `de` and `fr` configured and only `de` filled in,
1445
+ * failing on unfilled work makes `fr` fail and takes the whole import with it, even though `de`
1446
+ * imported perfectly. Locale-at-a-time handoff is this feature's most common workflow.
1447
+ * 3. {@link LocaleSummary.malformedRows} structurally cannot signal lost work. The Status column is
1448
+ * the only reachable rejection in the workbook reader; every other field is an unconstrained
1449
+ * string or a tolerant parse. So a row is malformed on its Status cell alone, entirely
1450
+ * independently of whether its Translation cell was filled. An untouched sheet with a mangled
1451
+ * Status column (spreadsheet autocorrect, a CAT-tool round trip) is all-malformed with zero lost
1452
+ * work. A bucket that cannot distinguish dropped work from absent work cannot honestly drive a
1453
+ * failure status.
1454
+ * 4. {@link LocaleSummary.duplicateKeys} never could: the first occurrence wins and the intent is
1455
+ * honoured.
1436
1456
  */
1437
1457
  readonly status: "succeeded" | "partial" | "failed";
1438
1458
  /**
@@ -1513,11 +1533,16 @@ interface LocaleSummary {
1513
1533
  */
1514
1534
  readonly needsReview: readonly NeedsReviewEntry[];
1515
1535
  /**
1516
- * Keys the workbook exported as `changed` (needing an updated translation) that the translator left
1517
- * blank, drifted or not, sorted by key. Pending work made visible: the row is skipped (nothing is
1518
- * written and the prior lock baseline is kept), but the key is surfaced so an unfinished import is
1519
- * never silently reported as done. Only a workbook import populates this; the provider path, which
1520
- * never leaves a candidate key unfilled by a person, always leaves it empty.
1536
+ * Keys the translator left blank that still need a translation, drifted or not, sorted by key.
1537
+ * Pending work made visible: the row is skipped (nothing is written and the prior lock baseline is
1538
+ * kept), but the key is surfaced so an unfinished import is never silently reported as done. Only a
1539
+ * workbook import populates this; the provider path, which never leaves a candidate key unfilled by
1540
+ * a person, always leaves it empty.
1541
+ *
1542
+ * Membership is decided by the import-time diff, not by the `status` the row was exported with: a
1543
+ * never-translated key exports as `new`, and a first handoff is all-new, which is the most common
1544
+ * unfilled case there is. A row exported as `changed` whose key no longer needs work by import time
1545
+ * is correspondingly excluded.
1521
1546
  */
1522
1547
  readonly unfilled: readonly string[];
1523
1548
  /**
@@ -1697,6 +1722,19 @@ interface LocaleFinishedEvent {
1697
1722
  readonly locale: string;
1698
1723
  /** Number of keys accepted (translated) for this locale. In a dry-run, the keys that would be translated. */
1699
1724
  readonly translated: number;
1725
+ /**
1726
+ * Zero-based index of this locale in the run's target-locale order, carrying the same value as the
1727
+ * matching {@link LocaleStartedEvent}. This is a correlation key, so a consumer can pair a finish
1728
+ * with its start without matching on the locale name.
1729
+ *
1730
+ * It is deliberately NOT a progress counter. The worker pool claims indices up front, so this is a
1731
+ * claim ordinal rather than a completion ordinal: under concurrency the locales finish in a
1732
+ * different order, and rendering `localeIndex + 1` of `totalLocales` on a finish produces a
1733
+ * counter that jumps around and never reaches its own total in order. Do not reintroduce one.
1734
+ */
1735
+ readonly localeIndex: number;
1736
+ /** Total number of target locales in this run, mirroring {@link LocaleStartedEvent}. */
1737
+ readonly totalLocales: number;
1700
1738
  }
1701
1739
  /** The run reached the end of its locale loop: how many locales it processed. */
1702
1740
  interface RunFinishedEvent {
@@ -2060,8 +2098,9 @@ interface WatchInput {
2060
2098
  readonly lockAcquireTimeoutMs?: number;
2061
2099
  /**
2062
2100
  * Passed through to every run's {@link TranslateInput.concurrency}: how many locales each cycle may
2063
- * run at once. Defaults to 1 (strictly serial). The per-cycle tracker is fresh, so a budgeted watch
2064
- * with concurrency greater than 1 still fails each run with `CONCURRENCY_BUDGET_CONFLICT`.
2101
+ * run at once. Defaults to 1 (strictly serial). Because the per-cycle budget tracker is fresh, a
2102
+ * value greater than 1 cannot be honored alongside a `maxTokens` budget; {@link watch} refuses that
2103
+ * combination at startup rather than letting every cycle fail on it.
2065
2104
  */
2066
2105
  readonly concurrency?: number;
2067
2106
  /**
@@ -2095,9 +2134,23 @@ interface WatchController {
2095
2134
  * `onRun` as a {@link WatchRunResult} and watching continues. Returns a controller whose `stop()`
2096
2135
  * closes the watcher and awaits the in-flight run.
2097
2136
  *
2137
+ * Both startup refusals are ordered deliberately: `concurrency` is resolved first because it is
2138
+ * decidable from the arguments alone, while the missing-source check is an I/O probe of project
2139
+ * state, so argument validation runs before any I/O and before the watcher exists. The concurrency
2140
+ * resolve passes `dryRun: false` unconditionally, which is exact rather than a simplification: there
2141
+ * is no dry-run watch, so the budget conflict always applies to a session.
2142
+ *
2143
+ * Resolving it here rather than leaving it to each cycle is the point, not an optimization: a watch
2144
+ * session is long-lived, so a per-cycle refusal would fail the initial run and every run after it,
2145
+ * indefinitely, while the session stayed alive and looked healthy.
2146
+ *
2098
2147
  * @param input - The config, optional cwd/debounce, and the `onRun` callback that receives each result.
2099
2148
  * @param deps - Optional composition seams (watcher, run, registry, provider builder, file system) for tests.
2100
2149
  * @returns A {@link WatchController}; call `stop()` to close the watcher and await the in-flight run.
2150
+ * @throws {@link SdkError} `CONCURRENCY_INVALID`: at startup only, when `concurrency` is defined but
2151
+ * not an integer of at least 1.
2152
+ * @throws {@link SdkError} `CONCURRENCY_BUDGET_CONFLICT`: at startup only, when `concurrency` is
2153
+ * greater than 1 while the config sets a `maxTokens` budget.
2101
2154
  * @throws {@link SdkError} `SOURCE_UNREADABLE`: at startup only, when the source locale file is absent.
2102
2155
  * @example
2103
2156
  * ```ts
package/dist/index.js CHANGED
@@ -31,27 +31,31 @@ var translationMemorySchema = z.object({
31
31
  function cacheFilePath(cwd) {
32
32
  return resolve(cwd, CACHE_FILE_NAME);
33
33
  }
34
+ var UNUSABLE = { memory: EMPTY_MEMORY, writable: true };
34
35
  async function readTranslationMemory(path, fs) {
35
36
  let read;
36
37
  try {
37
38
  read = await fs.readFileBounded(path, MAX_CACHE_FILE_BYTES);
38
39
  } catch {
39
- return EMPTY_MEMORY;
40
+ return UNUSABLE;
40
41
  }
41
42
  if (read.kind !== "ok") {
42
- return EMPTY_MEMORY;
43
+ return UNUSABLE;
43
44
  }
44
45
  let parsed;
45
46
  try {
46
47
  parsed = JSON.parse(read.content);
47
48
  } catch {
48
- return EMPTY_MEMORY;
49
+ return UNUSABLE;
49
50
  }
50
51
  const result = translationMemorySchema.safeParse(parsed);
51
- if (!result.success || result.data.version !== CURRENT_VERSION) {
52
- return EMPTY_MEMORY;
52
+ if (!result.success) {
53
+ return UNUSABLE;
53
54
  }
54
- return result.data;
55
+ if (result.data.version !== CURRENT_VERSION) {
56
+ return { memory: EMPTY_MEMORY, writable: false };
57
+ }
58
+ return { memory: result.data, writable: true };
55
59
  }
56
60
  function lookupMemory(memory, fingerprint, locale, contentHash2) {
57
61
  return memory.entries[fingerprint]?.[locale]?.[contentHash2];
@@ -103,7 +107,10 @@ async function feedTranslationMemory(cwd, fs, fingerprint, additionsByLocale) {
103
107
  }
104
108
  try {
105
109
  const path = cacheFilePath(cwd);
106
- const memory = await readTranslationMemory(path, fs);
110
+ const { memory, writable } = await readTranslationMemory(path, fs);
111
+ if (!writable) {
112
+ return;
113
+ }
107
114
  await writeTranslationMemory(path, applyAdditions(memory, fingerprint, additionsByLocale), fs);
108
115
  } catch {
109
116
  }
@@ -3309,6 +3316,12 @@ function extractPropertiesPlaceholders(value) {
3309
3316
  var UNICODE_ESCAPE = /^[0-9a-fA-F]{4}$/;
3310
3317
  var LEADING_WHITESPACE = /^[ \t\f]+/;
3311
3318
  var TRAILING_TERMINATOR = /(?:\r\n|\r|\n)$/;
3319
+ function detectLineTerminator(content) {
3320
+ if (content.includes("\r\n")) {
3321
+ return "\r\n";
3322
+ }
3323
+ return content.includes("\r") ? "\r" : "\n";
3324
+ }
3312
3325
  function isPropertiesWhitespace(char) {
3313
3326
  return char === " " || char === " " || char === "\f";
3314
3327
  }
@@ -3514,7 +3527,7 @@ async function readStructure(filePath) {
3514
3527
  outcome = await readBounded2(filePath);
3515
3528
  } catch (error) {
3516
3529
  if (isFileNotFound(error)) {
3517
- return [];
3530
+ return { items: [], terminator: "\n" };
3518
3531
  }
3519
3532
  throw new AdapterError("INVALID_STRUCTURE", "The destination file could not be read.");
3520
3533
  }
@@ -3524,13 +3537,16 @@ async function readStructure(filePath) {
3524
3537
  if (outcome.kind === "too-large") {
3525
3538
  throw new AdapterError("INPUT_TOO_LARGE", "The file exceeds the maximum allowed size.");
3526
3539
  }
3527
- return parseItems(outcome.content);
3540
+ return {
3541
+ items: parseItems(outcome.content),
3542
+ terminator: detectLineTerminator(outcome.content)
3543
+ };
3528
3544
  }
3529
3545
  async function serializePropertiesEntries(entries, filePath) {
3530
- const structure = await readStructure(filePath);
3546
+ const { items, terminator } = await readStructure(filePath);
3531
3547
  const emitted = /* @__PURE__ */ new Set();
3532
3548
  const lines = [];
3533
- for (const item of structure) {
3549
+ for (const item of items) {
3534
3550
  if (item.kind === "raw") {
3535
3551
  lines.push(item.text);
3536
3552
  continue;
@@ -3546,8 +3562,7 @@ async function serializePropertiesEntries(entries, filePath) {
3546
3562
  lines.push(formatEntry(key, entry.value));
3547
3563
  }
3548
3564
  }
3549
- return lines.length === 0 ? "" : `${lines.join("\n")}
3550
- `;
3565
+ return lines.length === 0 ? "" : `${lines.join(terminator)}${terminator}`;
3551
3566
  }
3552
3567
  function createPropertiesAdapter() {
3553
3568
  return createFlatFileAdapter({
@@ -4100,6 +4115,9 @@ function gateCandidateValue(sourceEntry, candidateValue, adapter) {
4100
4115
  if (assessValueDegeneracy(sourceEntry.value, candidateValue).degenerate) {
4101
4116
  return { accepted: false, reason: "degenerate" };
4102
4117
  }
4118
+ if (sourceEntry.value.trim() !== "" && candidateValue.trim() === "") {
4119
+ return { accepted: false, reason: "empty" };
4120
+ }
4103
4121
  return { accepted: true };
4104
4122
  }
4105
4123
 
@@ -4949,6 +4967,10 @@ function fanOutAccepted(params, group, acceptedRepresentative, outcome) {
4949
4967
  if (source === void 0) {
4950
4968
  continue;
4951
4969
  }
4970
+ if (!gateCandidateValue(source, acceptedRepresentative.value, params.adapter).accepted) {
4971
+ outcome.integrityMismatches.push(key);
4972
+ continue;
4973
+ }
4952
4974
  outcome.accepted.set(key, { value: acceptedRepresentative.value, source });
4953
4975
  if (flag !== void 0) {
4954
4976
  outcome.reviewFlags.set(key, flag);
@@ -5340,14 +5362,21 @@ async function createRunCacheState(input, config, cwd, dryRun, fs) {
5340
5362
  if (dryRun || input.cache === false) {
5341
5363
  return void 0;
5342
5364
  }
5343
- return {
5344
- memory: await readTranslationMemory(cacheFilePath(cwd), fs),
5345
- fingerprint: computeFingerprint(config),
5346
- additions: /* @__PURE__ */ new Map()
5365
+ const { memory, writable } = await readTranslationMemory(cacheFilePath(cwd), fs);
5366
+ return { memory, writable, fingerprint: computeFingerprint(config), additions: /* @__PURE__ */ new Map() };
5367
+ }
5368
+ function withCacheNotices(summaries, cache) {
5369
+ if (cache === void 0 || cache.writable) {
5370
+ return [...summaries];
5371
+ }
5372
+ const notice = {
5373
+ code: "CACHE_VERSION_UNRECOGNIZED",
5374
+ 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.`
5347
5375
  };
5376
+ return summaries.map((summary) => ({ ...summary, notices: [...summary.notices, notice] }));
5348
5377
  }
5349
5378
  async function recordCacheAdditions(cwd, cache, fs) {
5350
- if (cache === void 0 || cache.additions.size === 0) {
5379
+ if (cache === void 0 || cache.additions.size === 0 || !cache.writable) {
5351
5380
  return;
5352
5381
  }
5353
5382
  try {
@@ -5434,18 +5463,25 @@ async function runLocaleAt(context, targetLocales, localeIndex, runOne, results)
5434
5463
  context.onProgress?.({
5435
5464
  type: "locale-finished",
5436
5465
  locale: targetLocale,
5437
- translated: summary.translated.length
5466
+ translated: summary.translated.length,
5467
+ localeIndex,
5468
+ totalLocales: targetLocales.length
5438
5469
  });
5439
5470
  }
5440
5471
  async function runLocalesWithProgress(context, targetLocales, runOne, concurrency) {
5441
5472
  const totalLocales = targetLocales.length;
5442
5473
  const results = new Array(totalLocales);
5443
5474
  let nextIndex = 0;
5475
+ let abort;
5444
5476
  async function worker() {
5445
- while (nextIndex < totalLocales) {
5477
+ while (abort === void 0 && nextIndex < totalLocales) {
5446
5478
  const localeIndex = nextIndex;
5447
5479
  nextIndex += 1;
5448
- await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5480
+ try {
5481
+ await runLocaleAt(context, targetLocales, localeIndex, runOne, results);
5482
+ } catch (error) {
5483
+ abort ??= { reason: error };
5484
+ }
5449
5485
  }
5450
5486
  }
5451
5487
  const workerCount = Math.min(concurrency, totalLocales);
@@ -5454,6 +5490,9 @@ async function runLocalesWithProgress(context, targetLocales, runOne, concurrenc
5454
5490
  workers.push(worker());
5455
5491
  }
5456
5492
  await Promise.all(workers);
5493
+ if (abort !== void 0) {
5494
+ throw abort.reason;
5495
+ }
5457
5496
  return results.filter((summary) => summary !== void 0);
5458
5497
  }
5459
5498
  async function runAllLocalesDry(context, targetLocales, concurrency) {
@@ -5530,7 +5569,8 @@ async function translate2(input, deps = {}) {
5530
5569
  };
5531
5570
  const summaries = dryRun ? await runAllLocalesDry(context, config.targetLocales, concurrency) : await runAllLocalesLive(context, config.targetLocales, concurrency);
5532
5571
  input.onProgress?.({ type: "run-finished", localesCompleted: summaries.length });
5533
- const { succeeded, partial, failed } = partition(summaries);
5572
+ const locales = withCacheNotices(summaries, cache);
5573
+ const { succeeded, partial, failed } = partition(locales);
5534
5574
  const usage = summaries.reduce(
5535
5575
  (total, summary2) => combineUsage(total, summary2.usage),
5536
5576
  void 0
@@ -5538,7 +5578,7 @@ async function translate2(input, deps = {}) {
5538
5578
  const budgetSummary = toBudgetSummary(budget);
5539
5579
  const summary = {
5540
5580
  dryRun,
5541
- locales: summaries,
5581
+ locales,
5542
5582
  succeeded,
5543
5583
  partial,
5544
5584
  failed,
@@ -6110,12 +6150,12 @@ function classifyClear(row, sourceEntry, buckets) {
6110
6150
  buckets.withheld.add(row.key);
6111
6151
  return;
6112
6152
  }
6113
- buckets.accepted.set(row.key, { value: "", source: sourceEntry });
6153
+ buckets.accepted.set(row.key, { value: "", source: sourceEntry, cleared: true });
6114
6154
  }
6115
- function classifyRows(params, buckets) {
6155
+ function classifyRows(params, buckets, liveCandidates) {
6116
6156
  for (const row of params.sheet.rows) {
6117
6157
  if (row.translation === "") {
6118
- if (row.status === "changed") {
6158
+ if (liveCandidates.has(row.key)) {
6119
6159
  buckets.unfilled.push(row.key);
6120
6160
  }
6121
6161
  trackBlankDrift(row, params, buckets);
@@ -6134,7 +6174,11 @@ function classifyRows(params, buckets) {
6134
6174
  }
6135
6175
  const reason = judge(row, sourceEntry, params.adapter);
6136
6176
  if (reason === void 0) {
6137
- buckets.accepted.set(row.key, { value: row.translation, source: sourceEntry });
6177
+ buckets.accepted.set(row.key, {
6178
+ value: row.translation,
6179
+ source: sourceEntry,
6180
+ cleared: false
6181
+ });
6138
6182
  } else {
6139
6183
  buckets.mismatches.push(row.key);
6140
6184
  buckets.withheld.add(row.key);
@@ -6156,7 +6200,7 @@ function importLocale(params) {
6156
6200
  blankDrifted: /* @__PURE__ */ new Set(),
6157
6201
  unfilled: []
6158
6202
  };
6159
- classifyRows(params, buckets);
6203
+ classifyRows(params, buckets, /* @__PURE__ */ new Set([...diff2.missing, ...diff2.changed]));
6160
6204
  const rowKeys = new Set(params.sheet.rows.map((row) => row.key));
6161
6205
  const invalidIcuSource = [...new Set(params.sourceInvalidIcuKeys)].filter((key) => rowKeys.has(key)).sort();
6162
6206
  const translated = [...buckets.accepted.keys()].sort();
@@ -6214,8 +6258,10 @@ function mergeAccepted(target, accepted) {
6214
6258
  }
6215
6259
  function sheetCacheAdditions(accepted) {
6216
6260
  const record = {};
6217
- for (const [, { value, source }] of accepted) {
6218
- record[contentHash(source)] = value;
6261
+ for (const [, { value, source, cleared }] of accepted) {
6262
+ if (!cleared) {
6263
+ record[contentHash(source)] = value;
6264
+ }
6219
6265
  }
6220
6266
  return record;
6221
6267
  }
@@ -6401,6 +6447,7 @@ async function watch(input, deps = {}) {
6401
6447
  const cwd = input.cwd ?? process.cwd();
6402
6448
  const debounceMs = input.debounceMs ?? DEFAULT_DEBOUNCE_MS;
6403
6449
  const fs = deps.fs ?? defaultFs;
6450
+ resolveRunConcurrency(input.concurrency, false, input.config);
6404
6451
  const sourcePath = localeFilePath(cwd, input.config.files.pattern, input.config.sourceLocale);
6405
6452
  if (!await fs.fileExists(sourcePath)) {
6406
6453
  throw new SdkError(