@verbatra/sdk 0.5.0-next.4 → 0.6.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.d.ts CHANGED
@@ -273,7 +273,9 @@ declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
273
273
  apiKeyEnvVar: z.ZodOptional<z.ZodString>;
274
274
  }, z.core.$strict>;
275
275
  }, z.core.$strip>], "id">;
276
+ /** One validated provider section: the provider id plus that provider's own options. */
276
277
  type ProviderConfig = z.infer<typeof providerConfigSchema>;
278
+ /** The closed set of configurable provider ids. */
277
279
  type ProviderId = ProviderConfig["id"];
278
280
 
279
281
  /**
@@ -360,7 +362,14 @@ type VerbatraConfig = Omit<VerbatraConfigInput, "glossary"> & {
360
362
  glossary?: Readonly<Record<string, string>>;
361
363
  };
362
364
 
365
+ /**
366
+ * A provider's known model literals, with the open `string` arm stripped, so the authoring field
367
+ * accepts only that provider's models. Static authoring aid only; the runtime schema stays a
368
+ * non-empty string, so a brand-new model the installed SDK does not yet list is flagged but still
369
+ * runs.
370
+ */
363
371
  type KnownModels<M extends string> = M extends string ? (string extends M ? never : M) : never;
372
+ /** One provider's config variant with `options.model` narrowed to that provider's known literals. */
364
373
  type AuthoringVariant<Id extends ProviderId, M extends string> = Extract<ProviderConfig, {
365
374
  id: Id;
366
375
  }> extends infer Variant ? Variant extends {
@@ -372,6 +381,12 @@ type AuthoringVariant<Id extends ProviderId, M extends string> = Extract<Provide
372
381
  model: KnownModels<M>;
373
382
  };
374
383
  } : never : never;
384
+ /**
385
+ * The authoring view of one provider variant, keyed by id. LLM providers with a known SDK model
386
+ * union restrict `options.model` to that provider's literals. DeepL has no model field and
387
+ * openai-compatible's model is whatever the local server exposes (no known-model list to restrict
388
+ * against), so both are carried through unchanged.
389
+ */
375
390
  type AuthoringProviderVariant = {
376
391
  anthropic: AuthoringVariant<"anthropic", AnthropicModel>;
377
392
  openai: AuthoringVariant<"openai", OpenAiModel>;
@@ -457,9 +472,19 @@ interface SdkFs {
457
472
  writeFile(path: string, data: string): Promise<void>;
458
473
  /** Write raw bytes atomically (temp file, then rename over the target). Used for the workbook. */
459
474
  writeBytes(path: string, data: Uint8Array): Promise<void>;
475
+ /**
476
+ * Atomically create a file only if it does not already exist (O_EXCL semantics), creating any
477
+ * missing parent directory first. Returns true if this call created the file, false if a file
478
+ * was already present (no write performed). The backbone of {@link withLocaleWriteLock}'s
479
+ * exclusive-create lock: the parent-directory creation lives here so that primitive never touches
480
+ * `node:fs` directly and a fake `SdkFs` in tests never needs a real directory to exist.
481
+ */
482
+ createExclusive(path: string, data: string): Promise<boolean>;
483
+ /** Delete a file if present; a no-op if it is already absent. */
484
+ deleteFile(path: string): Promise<void>;
460
485
  }
461
486
 
462
- /** Where a config's resolved glossary came from, recorded for later Studio consumption. */
487
+ /** Where a config's resolved glossary came from: absent, inline, or a file at the recorded path. */
463
488
  type GlossaryProvenance = {
464
489
  readonly source: "none";
465
490
  } | {
@@ -561,22 +586,35 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
561
586
  *
562
587
  * - `CONFIG_NOT_FOUND`: no config was found by search, or an explicit `configPath` does not exist
563
588
  * (thrown by `loadConfig`).
564
- * - `CONFIG_INVALID`: a config was found but is unparseable or fails validation (thrown by `loadConfig`).
565
- * - `UNKNOWN_FORMAT`: no adapter is registered for the configured format (thrown by `translate`).
566
- * - `UNKNOWN_LOCALE`: a requested locale is not among the configured target locales (thrown by
567
- * `check`, `diff`, and `exportWorkbook` via the shared locale selection).
568
- * - `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw; wraps the provider's own error, including
569
- * a missing `*_API_KEY` reported as `MISSING_API_KEY` (thrown by `translate`, non-dry-run only).
570
- * - `SOURCE_UNREADABLE`: the source locale file is absent (thrown by `translate`, and by `watch` at startup).
571
- * - `SOURCE_INVALID`: the source locale file could not be read or parsed; wraps the adapter read error
572
- * (thrown by `translate`).
589
+ * - `CONFIG_INVALID`: a config was found but is unparseable or fails validation, or its glossary
590
+ * file path could not be resolved (thrown by `loadConfig`).
591
+ * - `UNKNOWN_FORMAT`: no adapter is registered for the configured format (thrown by every entry
592
+ * point that selects an adapter, before any file is read).
593
+ * - `UNKNOWN_LOCALE`: a requested locale is not among the configured target locales (thrown via
594
+ * the shared locale selection by `check`, `diff`, `keyIntegrity`, `lockState`, `exportWorkbook`,
595
+ * `keyValue`, `editEntry`, and `retranslateEntry`).
596
+ * - `UNKNOWN_KEY`: a requested key is not among the source resource's own keys (thrown by
597
+ * `keyValue`, `editEntry`, and `retranslateEntry`).
598
+ * - `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw; wraps the provider's own error,
599
+ * including a missing `*_API_KEY` reported as `MISSING_API_KEY` (thrown by non-dry-run
600
+ * `translate` and by `retranslateEntry`).
601
+ * - `SOURCE_UNREADABLE`: the source locale file is absent (thrown by every entry point that reads
602
+ * the source, and by `watch` at startup).
603
+ * - `SOURCE_INVALID`: the source locale file could not be read or parsed; wraps the adapter read
604
+ * error (thrown by every entry point that reads the source).
573
605
  * - `LOCK_FILE_INVALID`: the lock-file is present but corrupt, oversized, or at an unsupported
574
- * version (thrown by `translate`, `check`, `diff`, `exportWorkbook`, and `importWorkbook`).
606
+ * version (thrown wherever the lock-file is read or updated: `translate`, `check`, `diff`,
607
+ * `keyIntegrity`, `lockState`, `loadLockFile`, `exportWorkbook`, `importWorkbook`, `editEntry`,
608
+ * and `retranslateEntry`).
609
+ * - `LOCK_CONTENDED`: a locale's write lock (`withLocaleWriteLock`) could not be acquired before
610
+ * its timeout elapsed, because another process is holding it (or an orphaned lock file was left
611
+ * behind by a killed process); the message names the lock file's path (thrown by `translate`,
612
+ * `importWorkbook`, `editEntry`, and `retranslateEntry`).
575
613
  * - `LOCALE_FAILED` (NOT thrown): the fallback `code` recorded on a failed `LocaleSummary` when a
576
- * per-locale failure carries no string code of its own. See the surfaced-not-thrown distinction on
577
- * `translate`.
614
+ * per-locale failure carries no string code of its own. See the surfaced-not-thrown distinction
615
+ * on `translate`.
578
616
  */
579
- type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "UNKNOWN_LOCALE" | "PROVIDER_CONSTRUCTION_FAILED" | "SOURCE_UNREADABLE" | "SOURCE_INVALID" | "LOCK_FILE_INVALID" | "LOCALE_FAILED";
617
+ type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "UNKNOWN_LOCALE" | "UNKNOWN_KEY" | "PROVIDER_CONSTRUCTION_FAILED" | "SOURCE_UNREADABLE" | "SOURCE_INVALID" | "LOCK_FILE_INVALID" | "LOCK_CONTENDED" | "LOCALE_FAILED";
580
618
  /** The single structured error the SDK throws or records. Never carries a secret. */
581
619
  declare class SdkError extends Error {
582
620
  /** The stable {@link SdkErrorCode} for this failure; branch on this, not the message. */
@@ -775,6 +813,11 @@ declare class AdapterRegistry {
775
813
  register(adapter: FormatAdapter): this;
776
814
  private formats;
777
815
  private resolveByFormat;
816
+ /**
817
+ * Resolve by asking every registered adapter's `canHandle`. Several adapters can claim one file
818
+ * (all JSON adapters claim `.json`), so more than one match is reported as `ambiguous` rather
819
+ * than guessed at.
820
+ */
778
821
  private resolveByDetection;
779
822
  /**
780
823
  * Resolve the adapter for a file, by explicit format when given, otherwise by detection.
@@ -822,10 +865,11 @@ interface CheckDeps {
822
865
  readonly fs?: SdkFs;
823
866
  }
824
867
  /**
825
- * Report which keys are missing or stale per target locale, without calling a provider, writing any
826
- * file, or touching the lock. Each locale is reported as counts only (missing, stale, up-to-date);
827
- * a locale is `inSync` when nothing is missing or stale, and the top-level `inSync` is true only when
828
- * every checked locale is. Orphaned keys and integrity are not reported, since they concern a write.
868
+ * Reports how many keys are missing or stale per target locale, without calling a provider, writing
869
+ * any file, or touching the lock. Each locale is reported as counts only (missing, stale,
870
+ * up-to-date); a locale is `inSync` when nothing is missing or stale, and the top-level `inSync` is
871
+ * true only when every checked locale is. Orphaned keys and integrity are not reported, since they
872
+ * concern a write; use {@link diff} for the key lists.
829
873
  *
830
874
  * @param input - The validated config and which locales to check.
831
875
  * @param deps - Optional composition seams (registry, file system) for tests.
@@ -871,11 +915,11 @@ interface DiffDeps {
871
915
  readonly fs?: SdkFs;
872
916
  }
873
917
  /**
874
- * Report the exact pending change per target locale as three key lists (missing, changed, orphaned),
875
- * without calling a provider, writing any file, or touching the lock. This is the detailed sibling of
876
- * {@link check}. A locale's `hasPendingChanges` is driven by missing or changed only; orphaned keys are
877
- * reported but do not flip it, since a default `translate` run does not prune. The top-level
878
- * `hasPendingChanges` is true when any checked locale has pending changes.
918
+ * Reports the exact pending change per target locale as three key lists (missing, changed,
919
+ * orphaned), without calling a provider, writing any file, or touching the lock. The detailed
920
+ * sibling of {@link check}. A locale's `hasPendingChanges` is driven by missing or changed only;
921
+ * orphaned keys are reported but do not flip it, since a default `translate` run does not prune.
922
+ * The top-level `hasPendingChanges` is true when any checked locale has pending changes.
879
923
  *
880
924
  * @param input - The validated config and which locales to diff.
881
925
  * @param deps - Optional composition seams (registry, file system) for tests.
@@ -886,6 +930,238 @@ interface DiffDeps {
886
930
  */
887
931
  declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
888
932
 
933
+ /** Input for {@link editEntry}: the validated config, exactly one target locale/key pair, and the human-typed replacement value. */
934
+ interface EditEntryInput {
935
+ /** The validated configuration (typically from {@link loadConfig}). */
936
+ readonly config: VerbatraConfig;
937
+ /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
938
+ readonly cwd?: string;
939
+ /** The target locale to write the correction into. Must be a configured target locale. */
940
+ readonly locale: string;
941
+ /** The source key being corrected. Must exist in the source resource. */
942
+ readonly key: string;
943
+ /** The human-typed replacement value. */
944
+ readonly value: string;
945
+ }
946
+ /** Composition seam for {@link editEntry}: inject a registry and a file system for tests. No provider builder: this seam never calls a provider. */
947
+ interface EditEntryDeps {
948
+ readonly adapterRegistry?: AdapterRegistry;
949
+ readonly fs?: SdkFs;
950
+ }
951
+ /**
952
+ * The two-armed result of one edit attempt: acceptance carries the newly written value; a
953
+ * rejection carries the candidate value and which {@link gateCandidateValue} check failed it,
954
+ * with nothing written. Deliberately has no review-reasons field: a human-typed correction never
955
+ * touches a provider, so there is no provider-derived review signal to report.
956
+ */
957
+ type EditEntryResult = {
958
+ readonly accepted: true;
959
+ readonly value: string;
960
+ } | {
961
+ readonly accepted: false;
962
+ readonly reason: "placeholder" | "icu";
963
+ readonly value: string;
964
+ };
965
+ /**
966
+ * Writes exactly one human-typed correction for exactly one target locale, gated through the
967
+ * shared {@link gateCandidateValue} before anything reaches disk. On acceptance, writes the target
968
+ * locale file (merging just this one key into its current entries, every other key untouched) and
969
+ * updates the lock entry for this key only (see {@link updateLockFileLocale}'s `"merge"` mode); on
970
+ * rejection, writes nothing. Never calls a provider: {@link EditEntryDeps} has no provider seam,
971
+ * so there is no way to construct or call one.
972
+ *
973
+ * `locale` and `key` are resolved fresh on every call, never from a cache: `locale` against
974
+ * `config.targetLocales`, `key` against the source resource read fresh from disk.
975
+ *
976
+ * The target-file write and the lock-file update run inside one held `withLocaleWriteLock`
977
+ * critical section, so no second writer for this locale can observe the target file updated but
978
+ * the lock entry not yet, or vice versa. Reading the source, resolving the key, and selecting the
979
+ * adapter stay outside the lock: they are read-only and need no protection.
980
+ *
981
+ * Never touches `.verbatra-local/run-status.json`: that file is rewritten wholesale by
982
+ * `translate()`/`watch()` outside any locale lock, so patching it here would race that rewrite. A
983
+ * key edited here stays stale in that persisted snapshot until the next real run, which self-heals
984
+ * it since this call already advanced the lock hash.
985
+ *
986
+ * @param input - The validated config, the target locale, the source key, and the replacement value.
987
+ * @param deps - Optional composition seams (registry, file system) for tests.
988
+ * @returns The two-armed {@link EditEntryResult}.
989
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
990
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: `locale` is not among `config.targetLocales`.
991
+ * @throws {@link SdkError} `SOURCE_UNREADABLE` / `SOURCE_INVALID`: the source locale file is absent
992
+ * or unreadable.
993
+ * @throws {@link SdkError} `UNKNOWN_KEY`: `key` does not exist in the source resource.
994
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
995
+ * unsupported version.
996
+ * @throws {@link SdkError} `LOCK_CONTENDED`: this locale's write lock could not be acquired before
997
+ * its timeout, because another process is holding it.
998
+ */
999
+ declare function editEntry(input: EditEntryInput, deps?: EditEntryDeps): Promise<EditEntryResult>;
1000
+
1001
+ /**
1002
+ * One key's placeholder and ICU integrity result for one target locale. Only ever computed for a
1003
+ * "changed" key (a current source and a current target value both exist). `hasPlaceholders` is
1004
+ * false when the source value carries no placeholders, in which case `matches` is trivially true
1005
+ * and carries no signal on its own. `icuValid` is computed unconditionally, independent of
1006
+ * `matches`: unlike `gateCandidateValue`, which stops at the first failing check, this is an
1007
+ * information report and stays accurate even when the key already fails on placeholders; it is
1008
+ * always true for a non-ICU format. Carries no source or target string value: only the boolean
1009
+ * results and, on a placeholder mismatch, the specific tokens involved.
1010
+ */
1011
+ interface KeyIntegrityEntry {
1012
+ readonly key: string;
1013
+ readonly hasPlaceholders: boolean;
1014
+ readonly matches: boolean;
1015
+ readonly missing: readonly string[];
1016
+ readonly extra: readonly string[];
1017
+ readonly icuValid: boolean;
1018
+ }
1019
+ /** One target locale's integrity entries for the keys checked against it. */
1020
+ interface LocaleKeyIntegrity {
1021
+ readonly locale: string;
1022
+ readonly entries: readonly KeyIntegrityEntry[];
1023
+ }
1024
+ /** Input for {@link keyIntegrity}: the validated config, which locales to check, and an optional key filter. */
1025
+ interface KeyIntegrityInput {
1026
+ /** The validated configuration (typically from {@link loadConfig}). */
1027
+ readonly config: VerbatraConfig;
1028
+ /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
1029
+ readonly cwd?: string;
1030
+ /** Subset of target locales to check; defaults to all configured target locales. */
1031
+ readonly locales?: readonly string[];
1032
+ /** Restrict the check to these keys; only the ones that are "changed" for a locale are checked. Defaults to every changed key. */
1033
+ readonly keys?: readonly string[];
1034
+ }
1035
+ /** Composition seam for {@link keyIntegrity}: inject a registry and a file system for tests. */
1036
+ interface KeyIntegrityDeps {
1037
+ readonly adapterRegistry?: AdapterRegistry;
1038
+ readonly fs?: SdkFs;
1039
+ }
1040
+ /**
1041
+ * Runs the placeholder check and the ICU message-validity check for each selected target locale's
1042
+ * "changed" keys (present with a current value on both sides, per core's `diffResources`) and
1043
+ * reports a per-key result. Uses the adapter's own `comparePlaceholders` when present (the
1044
+ * branch-aware ICU path), otherwise core's `checkPlaceholders` over the stored placeholder lists.
1045
+ * `icuValid` is `adapter.validateMessage(target)`, computed unconditionally and independently of
1046
+ * the placeholder result, so a target that is placeholder-valid but syntactically invalid ICU is
1047
+ * still reported accurately. Read-only: it calls no provider, writes no file, and never touches
1048
+ * the lock. Missing and orphaned keys are never checked, since one side's value does not exist for
1049
+ * those.
1050
+ *
1051
+ * The returned entries never carry a source or target string value, only the boolean results and,
1052
+ * on a placeholder mismatch, the specific tokens involved.
1053
+ *
1054
+ * @param input - The validated config, which locales to check, and an optional key filter.
1055
+ * @param deps - Optional composition seams (registry, file system) for tests.
1056
+ * @returns One entry per checked locale, each carrying the integrity result for its changed keys
1057
+ * (narrowed to `input.keys` when supplied).
1058
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`,
1059
+ * `LOCK_FILE_INVALID`, or `UNKNOWN_LOCALE`, with the same meanings as in {@link diff}.
1060
+ */
1061
+ declare function keyIntegrity(input: KeyIntegrityInput, deps?: KeyIntegrityDeps): Promise<readonly LocaleKeyIntegrity[]>;
1062
+
1063
+ /** Input for {@link keyValue}: the validated config and exactly one target locale/key pair. */
1064
+ interface KeyValueInput {
1065
+ /** The validated configuration (typically from {@link loadConfig}). */
1066
+ readonly config: VerbatraConfig;
1067
+ /** Directory the file pattern resolves against; defaults to cwd. */
1068
+ readonly cwd?: string;
1069
+ /** The target locale to read the current translation from. Must be a configured target locale. */
1070
+ readonly locale: string;
1071
+ /** The source key to read. Must exist in the source resource. */
1072
+ readonly key: string;
1073
+ }
1074
+ /** Composition seam for {@link keyValue}: inject a registry and a file system for tests. */
1075
+ interface KeyValueDeps {
1076
+ readonly adapterRegistry?: AdapterRegistry;
1077
+ readonly fs?: SdkFs;
1078
+ }
1079
+ /**
1080
+ * The current source and target values for one key/locale pair. `target` is absent exactly when
1081
+ * the key does not yet exist in that target locale; a present-but-empty-string target is a real
1082
+ * value and is returned as such.
1083
+ */
1084
+ interface KeyValueResult {
1085
+ readonly source: string;
1086
+ readonly target?: string;
1087
+ }
1088
+ /**
1089
+ * Reads a key's current source and target values for exactly one target locale. Read-only: it
1090
+ * calls no provider, writes no file, and mutates nothing. Exposes only the current values on disk,
1091
+ * never a previous one.
1092
+ *
1093
+ * `locale` and `key` are resolved fresh on every call, so a caller feeding an edit dialog with
1094
+ * this result and then submitting through `editEntry` is always working from live data, never a
1095
+ * cached snapshot.
1096
+ *
1097
+ * @param input - The validated config, the target locale, and the source key to read.
1098
+ * @param deps - Optional composition seams (registry, file system) for tests.
1099
+ * @returns The current source value and, when present, the current target value.
1100
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1101
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: `locale` is not among `config.targetLocales`.
1102
+ * @throws {@link SdkError} `SOURCE_UNREADABLE` / `SOURCE_INVALID`: the source locale file is absent
1103
+ * or unreadable.
1104
+ * @throws {@link SdkError} `UNKNOWN_KEY`: `key` does not exist in the source resource.
1105
+ */
1106
+ declare function keyValue(input: KeyValueInput, deps?: KeyValueDeps): Promise<KeyValueResult>;
1107
+
1108
+ /**
1109
+ * One locale file reduced to a content hash per key, taken at one point in time. Two snapshots of
1110
+ * the same file can be compared with {@link diffLocaleSnapshots} to count what changed between
1111
+ * them.
1112
+ */
1113
+ interface LocaleFileSnapshot {
1114
+ /** The locale this snapshot was read for (the configured source locale or a target locale). */
1115
+ readonly locale: string;
1116
+ /** Content hash per key, from core's `contentHash`. */
1117
+ readonly hashes: ReadonlyMap<string, string>;
1118
+ }
1119
+ /** Input for {@link readLocaleFileSnapshot}: the validated config and which locale to snapshot. */
1120
+ interface ReadLocaleFileSnapshotInput {
1121
+ /** The validated configuration (typically from {@link loadConfig}). */
1122
+ readonly config: VerbatraConfig;
1123
+ /** The locale to snapshot: the configured source locale or any configured target locale. */
1124
+ readonly locale: string;
1125
+ /** Directory the file pattern resolves against; defaults to cwd. */
1126
+ readonly cwd?: string;
1127
+ }
1128
+ /** Composition seam for {@link readLocaleFileSnapshot}: inject a registry and a file system for tests. */
1129
+ interface ReadLocaleFileSnapshotDeps {
1130
+ readonly adapterRegistry?: AdapterRegistry;
1131
+ readonly fs?: SdkFs;
1132
+ }
1133
+ /**
1134
+ * Reads one locale file through the configured adapter and reduces it to a content hash per key.
1135
+ * Uses the same tolerant read path as {@link readTarget}: a file that does not exist yet reads as
1136
+ * an empty snapshot rather than throwing, so a caller can establish a baseline before a locale's
1137
+ * first write.
1138
+ *
1139
+ * @param input - The validated config, the locale to snapshot, and the directory to resolve against.
1140
+ * @param deps - Optional composition seams (registry, file system) for tests.
1141
+ * @returns The locale's per-key content hash snapshot.
1142
+ * @throws {@link SdkError} `UNKNOWN_FORMAT` when the configured format has no registered adapter;
1143
+ * rejects with the adapter's own error when the file exists but its content is malformed.
1144
+ */
1145
+ declare function readLocaleFileSnapshot(input: ReadLocaleFileSnapshotInput, deps?: ReadLocaleFileSnapshotDeps): Promise<LocaleFileSnapshot>;
1146
+ /** A locale file's added, changed, and removed key counts between two of its own snapshots. Counts only, never key names. */
1147
+ interface LocaleSnapshotDelta {
1148
+ readonly added: number;
1149
+ readonly changed: number;
1150
+ readonly removed: number;
1151
+ }
1152
+ /**
1153
+ * Compares two snapshots of the same locale file, taken at different points in time, and counts
1154
+ * how many keys were added, changed, or removed. A direct key-to-hash comparison rather than
1155
+ * core's `diffResources`: that function's source/target/baseline shape exists for cross-resource
1156
+ * comparison, and forcing a same-file, two-points-in-time comparison through it would need a
1157
+ * confusing relabeling (missing becomes removed, orphaned becomes added).
1158
+ *
1159
+ * @param previous - The earlier snapshot of the file.
1160
+ * @param current - The later snapshot of the same file.
1161
+ * @returns The counts of keys added, changed, and removed between the two snapshots.
1162
+ */
1163
+ declare function diffLocaleSnapshots(previous: LocaleFileSnapshot, current: LocaleFileSnapshot): LocaleSnapshotDelta;
1164
+
889
1165
  /** Per-locale key count and drift against the source and target files, from the recorded lock baseline. */
890
1166
  interface LockLocaleState {
891
1167
  /** The target locale this entry reports on. */
@@ -941,6 +1217,86 @@ interface LockStateDeps {
941
1217
  */
942
1218
  declare function lockState(input: LockStateInput, deps?: LockStateDeps): Promise<LockStateResult>;
943
1219
 
1220
+ /** Builds the provider from its config. Injectable so tests stay offline. */
1221
+ type CreateProvider = (config: ProviderConfig) => TranslationProvider;
1222
+
1223
+ /** Input for {@link retranslateEntry}: the validated config and exactly one target locale/key pair. */
1224
+ interface RetranslateEntryInput {
1225
+ /** The validated configuration (typically from {@link loadConfig}). */
1226
+ readonly config: VerbatraConfig;
1227
+ /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
1228
+ readonly cwd?: string;
1229
+ /** The target locale to retranslate the key into. Must be a configured target locale. */
1230
+ readonly locale: string;
1231
+ /** The source key to retranslate. Must exist in the source resource. */
1232
+ readonly key: string;
1233
+ }
1234
+ /** Composition seam for {@link retranslateEntry}: inject a registry, a provider builder, and a file system for tests. */
1235
+ interface RetranslateEntryDeps {
1236
+ readonly adapterRegistry?: AdapterRegistry;
1237
+ readonly createProvider?: CreateProvider;
1238
+ readonly fs?: SdkFs;
1239
+ }
1240
+ /**
1241
+ * The two-armed result of one retranslate attempt: `accepted` carries the newly written value and
1242
+ * any derived "needs review" reasons; a rejection carries the candidate value and which
1243
+ * {@link gateCandidateValue} check failed it, without writing anything.
1244
+ */
1245
+ type RetranslateEntryResult = {
1246
+ readonly accepted: true;
1247
+ readonly value: string;
1248
+ readonly reviewReasons: readonly ReviewReasonCode[];
1249
+ } | {
1250
+ readonly accepted: false;
1251
+ readonly reason: "placeholder" | "icu";
1252
+ readonly value: string;
1253
+ };
1254
+ /**
1255
+ * Retranslate exactly one key for exactly one target locale: a single-entry `translateBatch` call
1256
+ * through the same provider registry `translate()` uses, gated through the shared
1257
+ * {@link gateCandidateValue} before anything reaches disk. On acceptance, writes the target locale
1258
+ * file (merging just this one key into its current entries, every other key untouched) and updates
1259
+ * the lock entry for this key only (see {@link updateLockFileLocale}'s `"merge"` mode); on rejection,
1260
+ * writes nothing. Reuses exactly the building blocks `translate()` already uses
1261
+ * (`selectProvider`/`provider.translateBatch`) and nothing else: no bespoke per-provider plumbing.
1262
+ *
1263
+ * `locale` and `key` are resolved fresh on every call, never cached: `locale` against
1264
+ * `config.targetLocales` (`UNKNOWN_LOCALE`), `key` against the source resource's own keys, read
1265
+ * fresh from disk (`UNKNOWN_KEY`), via a `Map`-backed lookup, never `key in obj` or
1266
+ * `obj[key] !== undefined` against a plain object.
1267
+ *
1268
+ * The target-file write and the lock-file update run inside the same held
1269
+ * `withLocaleWriteLock(cwd, locale, fs, ...)` critical section as the provider call itself, so no
1270
+ * second writer for this locale can ever observe the target file updated but the lock entry not
1271
+ * yet, or vice versa. The same locking also covers `translate()`/`watch()` and `importWorkbook()`.
1272
+ *
1273
+ * Not atomic across the two writes for a single caller's own run: the target locale file is
1274
+ * written before the lock entry is updated. If `updateLockFileLocale` then throws
1275
+ * (`LOCK_FILE_INVALID` for a corrupt lock-file, or `LOCK_CONTENDED` if its own internal lock-file
1276
+ * guard cannot be acquired before its timeout), the target file already carries the new value
1277
+ * while the lock still records the old hash, and the thrown error does not imply nothing was
1278
+ * saved. The same ordering, and the same partial-write shape on that rare path, also exists in
1279
+ * `translate()` and `importWorkbook()`.
1280
+ *
1281
+ * @param input - The validated config, the target locale, and the source key to retranslate.
1282
+ * @param deps - Optional composition seams (registry, provider builder, file system) for tests.
1283
+ * @returns The two-armed {@link RetranslateEntryResult}.
1284
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1285
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: `locale` is not among `config.targetLocales`.
1286
+ * @throws {@link SdkError} `SOURCE_UNREADABLE` / `SOURCE_INVALID`: the source locale file is absent
1287
+ * or unreadable.
1288
+ * @throws {@link SdkError} `UNKNOWN_KEY`: `key` does not exist in the source resource.
1289
+ * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw (wraps a
1290
+ * missing `*_API_KEY` as `MISSING_API_KEY`).
1291
+ * @throws {@link ProviderError} the provider call itself failed, or returned no value at all for
1292
+ * this key (`INVALID_RESPONSE`).
1293
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
1294
+ * unsupported version.
1295
+ * @throws {@link SdkError} `LOCK_CONTENDED`: this locale's write lock could not be acquired before
1296
+ * its timeout, because another process (another CLI run, or a second Studio write) is holding it.
1297
+ */
1298
+ declare function retranslateEntry(input: RetranslateEntryInput, deps?: RetranslateEntryDeps): Promise<RetranslateEntryResult>;
1299
+
944
1300
  /** A stable code for an SDK-originated notice (not a provider notice). */
945
1301
  type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED";
946
1302
  /** Summed token usage across every provider call in a scope (one locale, or the whole run). */
@@ -995,7 +1351,7 @@ interface LocaleSummary {
995
1351
  /** Whether this locale's run succeeded or failed (a failure does not abort the run). */
996
1352
  readonly status: "succeeded" | "failed";
997
1353
  /**
998
- * Keys translated and written this run. In dry-run, the keys that WOULD be translated
1354
+ * Keys translated and written this run. In dry-run, the keys that would be translated
999
1355
  * (the provider is not called and nothing is written).
1000
1356
  */
1001
1357
  readonly translated: readonly string[];
@@ -1005,7 +1361,7 @@ interface LocaleSummary {
1005
1361
  readonly orphaned: readonly string[];
1006
1362
  /**
1007
1363
  * Orphaned keys actually removed this run because pruning was on. In a dry-run with pruning on, the keys
1008
- * that WOULD be removed. Empty when pruning is off (the orphans then survive and are reported in
1364
+ * that would be removed. Empty when pruning is off (the orphans then survive and are reported in
1009
1365
  * `orphaned` only). A subset of `orphaned`; never includes a source-present key.
1010
1366
  */
1011
1367
  readonly pruned: readonly string[];
@@ -1063,7 +1419,7 @@ interface LocaleSummary {
1063
1419
  */
1064
1420
  readonly needsReview: readonly NeedsReviewEntry[];
1065
1421
  /**
1066
- * Present only when status is "failed": a structured, secret-free error. `code` is a PRESERVED string
1422
+ * Present only when status is "failed": a structured, secret-free error. `code` is a preserved string
1067
1423
  * (the underlying provider/adapter error's `code`, or `"LOCALE_FAILED"` as a fallback), intentionally
1068
1424
  * wider than {@link SdkErrorCode}, so do not treat it as a closed set.
1069
1425
  */
@@ -1091,8 +1447,68 @@ interface RunSummary {
1091
1447
  readonly budget?: RunBudget;
1092
1448
  }
1093
1449
 
1094
- /** Builds the provider from its config. Injectable so tests stay offline. */
1095
- type CreateProvider = (config: ProviderConfig) => TranslationProvider;
1450
+ /**
1451
+ * One target locale's snapshot inside the persisted run-status file. `status` is carried over from
1452
+ * `LocaleSummary.status` unchanged, so an empty `needsReview` on a failed locale is never misread as
1453
+ * "this locale ran clean with nothing to review".
1454
+ */
1455
+ interface RunStatusLocale {
1456
+ readonly locale: string;
1457
+ readonly status: "succeeded" | "failed";
1458
+ /** The full, unmodified list from `LocaleSummary.needsReview`; already bounded and content-free. */
1459
+ readonly needsReview: readonly NeedsReviewEntry[];
1460
+ /** Absent exactly when the source `LocaleSummary.usage` was absent; never a fabricated zero. */
1461
+ readonly usage?: UsageSummary;
1462
+ }
1463
+ /**
1464
+ * The persisted shape of `.verbatra-local/run-status.json`: one whole-run snapshot, overwritten on
1465
+ * every non-dry-run `translate()`/`watch()` run that reaches the end of its per-locale loop. Contains
1466
+ * only the review-flag and token/usage data already computed for `RunSummary`; never a translation
1467
+ * string, a provider error message, or a provider notice message.
1468
+ */
1469
+ interface RunStatusFile {
1470
+ readonly version: number;
1471
+ /** ISO timestamp of when this snapshot was written. */
1472
+ readonly generatedAt: string;
1473
+ /** Summed run-wide token usage, mirroring `RunSummary.usage`; absent under the same conditions. */
1474
+ readonly usage?: UsageSummary;
1475
+ /** The run-wide token-budget outcome, mirroring `RunSummary.budget`; present only when configured. */
1476
+ readonly budget?: RunBudget;
1477
+ readonly locales: readonly RunStatusLocale[];
1478
+ }
1479
+
1480
+ /** Input for {@link runStatus}: the persisted file is self-contained, so no `config` is needed. */
1481
+ interface RunStatusInput {
1482
+ /** Directory the run-status file resolves against; defaults to cwd. */
1483
+ readonly cwd?: string;
1484
+ }
1485
+ /** Composition seam for {@link runStatus}: inject a file system for tests. */
1486
+ interface RunStatusDeps {
1487
+ readonly fs?: SdkFs;
1488
+ }
1489
+ /**
1490
+ * The persisted run-status snapshot, or its absence. `available: false` covers a project that has
1491
+ * never run a non-dry-run `translate()`/`watch()`, exactly like every other degrade case
1492
+ * {@link runStatus} folds into it: never a distinct error, so a caller does not need to special-case
1493
+ * "first run" apart from "corrupt file" apart from "unrecognized version".
1494
+ */
1495
+ type RunStatusResult = {
1496
+ readonly available: false;
1497
+ } | ({
1498
+ readonly available: true;
1499
+ } & RunStatusFile);
1500
+ /**
1501
+ * Read the most recent run's persisted review-flag and token/usage snapshot from
1502
+ * `.verbatra-local/run-status.json`, without calling a provider, writing any file, or mutating the
1503
+ * lock-file. Never throws: a missing file, invalid JSON, a schema mismatch, or an unrecognized
1504
+ * `version` all degrade to `{ available: false }`, since this file is best-effort telemetry, not a
1505
+ * correctness baseline (contrast {@link lockState}, which can throw `LOCK_FILE_INVALID`).
1506
+ *
1507
+ * @param input - Only the directory the file resolves against; no config is needed.
1508
+ * @param deps - Optional composition seam (file system) for tests.
1509
+ * @returns `{ available: false }`, or `{ available: true, ...the persisted fields }`.
1510
+ */
1511
+ declare function runStatus(input?: RunStatusInput, deps?: RunStatusDeps): Promise<RunStatusResult>;
1096
1512
 
1097
1513
  /** Everything the one-shot run needs: the validated config and where/how to run it. */
1098
1514
  interface TranslateInput {
@@ -1133,12 +1549,23 @@ interface TranslateDeps {
1133
1549
  * locales that succeeded. Dry-run reads + diffs + reports without constructing/calling
1134
1550
  * the provider and without writing any file or the lock-file.
1135
1551
  *
1136
- * A per-locale failure does NOT throw: it is recorded on that locale's {@link LocaleSummary} as
1552
+ * A per-locale failure does not throw: it is recorded on that locale's {@link LocaleSummary} as
1137
1553
  * `status: "failed"` with a secret-free `{ code, message }`, where `code` is a preserved string (the
1138
1554
  * underlying provider/adapter code, or `"LOCALE_FAILED"` as a fallback), not necessarily an
1139
1555
  * {@link SdkErrorCode}. DeepL notices, integrity mismatches, and invalid-ICU source keys likewise
1140
1556
  * surface on each `LocaleSummary`, never as throws.
1141
1557
  *
1558
+ * A dry-run reads the lock once, outside any lock, and reuses that one snapshot for every locale:
1559
+ * it never writes, so there is nothing to serialize against. A live run instead re-reads the lock
1560
+ * fresh inside each locale's own write lock (see `runLiveLocale`), so two concurrent live calls
1561
+ * never both diff against the same stale pre-loop snapshot. The token-budget tracker is fresh per
1562
+ * invocation: the `maxTokens` ceiling is per-run, not cumulative across watch cycles.
1563
+ *
1564
+ * On a non-dry-run that reaches the end of the loop, the run's review-flag and token/usage data is
1565
+ * also written to `.verbatra-local/run-status.json` (readable back through {@link runStatus}). This
1566
+ * write is best-effort: any failure is caught and swallowed, never re-thrown and never reflected on
1567
+ * the returned {@link RunSummary}.
1568
+ *
1142
1569
  * @param input - The validated config and run options (cwd, dryRun, prune, generatePlurals).
1143
1570
  * @param deps - Optional composition seams (registry, provider builder, file system) for tests.
1144
1571
  * @returns A {@link RunSummary}: the per-locale {@link LocaleSummary}s and the succeeded/failed locale lists.
@@ -1244,7 +1671,11 @@ interface ImportWorkbookDeps {
1244
1671
  * structured {@link SdkError}. A per-sheet failure (a locale not in config, a broken-round-trip key,
1245
1672
  * a write failure) is isolated as that locale's `status: "failed"`, not a throw; per-row rejections
1246
1673
  * are withheld and reported on the locale. Dry-run validates and reports without writing any locale or
1247
- * lock file.
1674
+ * lock file, and skips lock acquisition (there is nothing to protect).
1675
+ *
1676
+ * The lock-file is read once, up front, for every sheet's diff baseline. On a non-dry-run, each
1677
+ * sheet's write-and-lock-update step then holds that locale's `withLocaleWriteLock` for its whole
1678
+ * critical section, so a concurrent writer touching the same locale can never interleave with it.
1248
1679
  *
1249
1680
  * @param input - The validated config, the workbook path, and run options.
1250
1681
  * @param deps - Optional composition seams (registry, file system) for tests.
@@ -1414,4 +1845,4 @@ interface WatchController {
1414
1845
  */
1415
1846
  declare function watch(input: WatchInput, deps?: WatchDeps): Promise<WatchController>;
1416
1847
 
1417
- export { type BudgetBehavior, type CheckDeps, type CheckInput, type CheckSummary, type ConfigSource, type CreateProvider, type CreateWatcher, DEFAULT_WORKBOOK_PATH, type DiffDeps, type DiffInput, type DiffSummary, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type GlossaryProvenance, type ImportWorkbookDeps, type ImportWorkbookInput, LOCK_FILE_NAME, type LoadConfigOptions, type LoadLockFileDeps, type LoadLockFileInput, type LoadedConfig, type LocaleCheckSummary, type LocaleDiff, type LocaleNotice, type LocaleSummary, type LockFile, type LockLocaleState, type LockStateDeps, type LockStateInput, type LockStateResult, type NeedsReviewEntry, type ProviderConfig, type ProviderId, type ReviewReasonCode, type RunBudget, type RunSummary, type RunTranslate, type ScaffoldableProviderId, SdkError, type SdkErrorCode, type SdkFs, type SdkNotice, type SdkNoticeCode, type SupportedFormat$1 as SupportedFormat, type TranslateDeps, type TranslateInput, type UsageSummary, type VerbatraConfig, type VerbatraConfigInput, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, check, defineConfig, diff, exportWorkbook, importWorkbook, loadConfig, loadConfigWithMeta, loadLockFile, lockState, scaffoldingMetadata, translate, verbatraConfigSchema, watch };
1848
+ export { type BudgetBehavior, type CheckDeps, type CheckInput, type CheckSummary, type ConfigSource, type CreateProvider, type CreateWatcher, DEFAULT_WORKBOOK_PATH, type DiffDeps, type DiffInput, type DiffSummary, type EditEntryDeps, type EditEntryInput, type EditEntryResult, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type GlossaryProvenance, type ImportWorkbookDeps, type ImportWorkbookInput, type KeyIntegrityDeps, type KeyIntegrityEntry, type KeyIntegrityInput, type KeyValueDeps, type KeyValueInput, type KeyValueResult, LOCK_FILE_NAME, type LoadConfigOptions, type LoadLockFileDeps, type LoadLockFileInput, type LoadedConfig, type LocaleCheckSummary, type LocaleDiff, type LocaleFileSnapshot, type LocaleKeyIntegrity, type LocaleNotice, type LocaleSnapshotDelta, type LocaleSummary, type LockFile, type LockLocaleState, type LockStateDeps, type LockStateInput, type LockStateResult, type NeedsReviewEntry, type ProviderConfig, type ProviderId, type ReadLocaleFileSnapshotDeps, type ReadLocaleFileSnapshotInput, type RetranslateEntryDeps, type RetranslateEntryInput, type RetranslateEntryResult, type ReviewReasonCode, type RunBudget, type RunStatusDeps, type RunStatusFile, type RunStatusInput, type RunStatusLocale, type RunStatusResult, type RunSummary, type RunTranslate, type ScaffoldableProviderId, SdkError, type SdkErrorCode, type SdkFs, type SdkNotice, type SdkNoticeCode, type SupportedFormat$1 as SupportedFormat, type TranslateDeps, type TranslateInput, type UsageSummary, type VerbatraConfig, type VerbatraConfigInput, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, check, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate, verbatraConfigSchema, watch };