@verbatra/sdk 0.7.1 → 0.9.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
@@ -4,8 +4,19 @@ import { Interactions } from '@google/genai';
4
4
  import OpenAI from 'openai';
5
5
 
6
6
  /**
7
- * A single, format-neutral translation unit. Placeholders are supplied already
8
- * extracted; core never derives them from the value.
7
+ * A single, format-neutral translation unit. Placeholders are supplied already extracted; core never
8
+ * derives them from the value, so an adapter that reads a file is the one authority on what counts as
9
+ * a placeholder in that format.
10
+ *
11
+ * The fields are: `key`, the entry's identifier within its namespace, non-empty and already flattened
12
+ * to the dotted path an adapter produced; `namespace`, the namespace the entry belongs to, empty when
13
+ * the format has no namespacing; `value`, the translatable string itself, which is untrusted input and
14
+ * may be empty; `description` and `meaning`, both optional free-form context that a provider is told to
15
+ * use only for disambiguation and never to translate or echo back; `placeholders`, the format's
16
+ * placeholder tokens in document order, used for the output integrity check and to decide which entries
17
+ * a placeholder-blind provider must withhold; and `isPlural`, whether the entry carries plural forms
18
+ * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
19
+ * decides and which feeds the content hash.
9
20
  */
10
21
  declare const translationEntrySchema$1: z.ZodObject<{
11
22
  key: z.ZodString;
@@ -16,12 +27,16 @@ declare const translationEntrySchema$1: z.ZodObject<{
16
27
  placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
17
28
  isPlural: z.ZodBoolean;
18
29
  }, z.core.$strip>;
19
- /** The validated shape of one translation unit; the inferred type of {@link translationEntrySchema}. */
30
+ /**
31
+ * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
32
+ * made deeply readonly. See that schema for what each field carries.
33
+ */
20
34
  type TranslationEntry$1 = Readonly<z.infer<typeof translationEntrySchema$1>>;
21
35
 
22
36
  /**
23
37
  * Result of comparing source placeholders against translated-output placeholders as MULTISETS
24
- * (counts matter). Always returned (never thrown) for an ordinary mismatch.
38
+ * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
39
+ * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
25
40
  */
26
41
  interface PlaceholderIntegrityResult$1 {
27
42
  /** True when nothing is missing or extra: the multisets match, regardless of order. */
@@ -30,13 +45,22 @@ interface PlaceholderIntegrityResult$1 {
30
45
  readonly missing: readonly string[];
31
46
  /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
32
47
  readonly extra: readonly string[];
33
- /** Same multiset present in both, but in a different order. */
48
+ /** Same multiset present in both, but in a different order. Never true alongside a mismatch. */
34
49
  readonly reordered: boolean;
35
50
  }
36
51
 
37
- /** A provider is either a prompt-driven LLM or a dedicated machine-translation API. */
52
+ /**
53
+ * A provider is either a prompt-driven LLM (`llm`) or a dedicated machine-translation API
54
+ * (`machine-translation`). The distinction is descriptive, not dispatch: both kinds satisfy the same
55
+ * {@link TranslationProvider} contract, and callers use it to set expectations (an LLM accepts free-form
56
+ * context and a glossary term map, an MT API generally does not).
57
+ */
38
58
  type ProviderKind = "llm" | "machine-translation";
39
- /** Target tone for a translation. Maps to formality for machine-translation providers. */
59
+ /**
60
+ * Target tone for a translation. `formal` and `informal` request the corresponding register, and
61
+ * `neutral` requests neither. Machine-translation providers map it to their formality setting and may
62
+ * report a `FORMALITY_DOWNGRADED` {@link ProviderNotice} when they cannot apply it.
63
+ */
40
64
  type Tone = "formal" | "informal" | "neutral";
41
65
  /**
42
66
  * Produces the placeholder set of a value for the output integrity check. Supplied
@@ -45,16 +69,17 @@ type Tone = "formal" | "informal" | "neutral";
45
69
  */
46
70
  type PlaceholderExtractor = (value: string) => readonly string[];
47
71
  /**
48
- * Compares a source and translated value's placeholders directly, branch-aware, instead of
49
- * independently extracting each side's flat placeholder list first. Supplied by the caller (the SDK, for
50
- * a format whose adapter defines one, for example ICU plural/select) so it matches the entries' format;
51
- * ai-providers never derives it itself and never parses a specific format's message syntax.
72
+ * Compares a source and translated value directly instead of independently extracting each side's flat
73
+ * placeholder list first, which lets it express checks the flat lists cannot (branch-aware ICU
74
+ * comparison, or a one-directional rule). Supplied by the caller (the SDK, for a format whose adapter
75
+ * defines one) so it matches the entries' format; ai-providers never derives it itself and never parses
76
+ * a specific format's message syntax.
52
77
  */
53
78
  type PlaceholderComparator = (source: string, translated: string) => PlaceholderIntegrityResult$1;
54
79
  /**
55
80
  * A batch translation request. Format- and provider-neutral: it carries no prompt,
56
81
  * model, key, or other provider-specific field. The placeholder extractor is
57
- * mandatory (see validateRequest).
82
+ * mandatory and is checked before the data fields are parsed.
58
83
  */
59
84
  interface TranslateRequest {
60
85
  /** BCP-47 source locale of the entries (for example, "en"). */
@@ -65,26 +90,30 @@ interface TranslateRequest {
65
90
  readonly entries: readonly TranslationEntry$1[];
66
91
  /** Optional source-term to target-term map applied by glossary-capable providers. */
67
92
  readonly glossary?: Readonly<Record<string, string>>;
68
- /** Optional target tone; machine-translation providers map it to formality. */
93
+ /** Optional target {@link Tone}; machine-translation providers map it to formality. */
69
94
  readonly tone?: Tone;
70
95
  /** Mandatory placeholder extractor; the output integrity check runs against it. */
71
96
  readonly extractPlaceholders: PlaceholderExtractor;
72
97
  /**
73
- * Optional branch-aware placeholder comparator. When present, the output integrity check uses it
98
+ * Optional whole-value placeholder comparator. When present, the output integrity check uses it
74
99
  * instead of independently extracting each side's placeholders with {@link extractPlaceholders} and
75
- * diffing the flat lists. Absent for a format with no plural/select sub-message structure.
100
+ * diffing the flat lists. Supplied for a format whose adapter defines one, whether because the
101
+ * comparison is branch-aware or because it adds a check the flat lists cannot express. Absent
102
+ * otherwise.
76
103
  */
77
104
  readonly comparePlaceholders?: PlaceholderComparator;
78
105
  /**
79
106
  * Optional cancellation signal for this batch. When aborted, an in-flight provider call rejects
80
- * with the abort, unwrapped, instead of a {@link ProviderError} (see `guardProviderCall`). Not a
81
- * plain-data field: it is never validated by `requestDataSchema` and never sent to a provider.
107
+ * with the abort, unwrapped, instead of a `ProviderError`. Not a plain-data field: it is
108
+ * never validated alongside the data fields and never sent to a provider.
82
109
  */
83
110
  readonly signal?: AbortSignal;
84
111
  }
85
112
  /** Token usage, when the provider reports it. Absent for providers without tokens (DeepL). */
86
113
  interface Usage {
114
+ /** Tokens consumed by the request, summed across the initial call and any repair round. */
87
115
  readonly inputTokens: number;
116
+ /** Tokens produced by the response, summed across the initial call and any repair round. */
88
117
  readonly outputTokens: number;
89
118
  }
90
119
  /**
@@ -113,25 +142,38 @@ interface ProviderNotice {
113
142
  }
114
143
  /**
115
144
  * Stable codes for a derived, per-key "needs review" signal. This is verbatra's own computed
116
- * assessment, never a raw model self-score: four are recomputable from plain source/translated
117
- * values (see the shared heuristic function), and `PROVIDER_DEGRADED` is applied by the caller from
118
- * a provider notice.
145
+ * assessment, never a raw model self-score: four are recomputable from plain source and translated
146
+ * values, and `PROVIDER_DEGRADED` is layered on afterwards from the batch's notices.
119
147
  *
120
148
  * - `LENGTH_RATIO_OUTLIER`: the translated value's length is far shorter or longer than the source's.
121
- * - `EQUALS_SOURCE`: the translated value equals the source value verbatim.
122
- * - `GLOSSARY_TERM_MISSED`: a configured glossary target term did not appear in the translation.
149
+ * Only considered once the trimmed source is long enough for the ratio to mean anything.
150
+ * - `EQUALS_SOURCE`: the translated value equals the source value once both are trimmed, so a
151
+ * difference in leading or trailing whitespace alone still counts as equal. Also requires that
152
+ * the locales differ and that the value contains at least one letter (so a bare symbol or number
153
+ * is not flagged).
154
+ * - `GLOSSARY_TERM_MISSED`: a configured glossary source term appeared in the source but its target
155
+ * term did not appear in the translation.
123
156
  * - `INTEGRITY_REORDERED`: the placeholder set matched but landed in a different order.
124
- * - `PROVIDER_DEGRADED`: the batch this key came from carried a graceful-degradation notice.
157
+ * - `PROVIDER_DEGRADED`: the batch this key came from carried a `FORMALITY_DOWNGRADED` or
158
+ * `GLOSSARY_IGNORED` notice, either of which can silently change wording. A
159
+ * `PLACEHOLDER_UNSUPPORTED` notice does not raise it, since the affected entries are withheld
160
+ * rather than degraded.
125
161
  */
126
162
  type ReviewReasonCode = "LENGTH_RATIO_OUTLIER" | "EQUALS_SOURCE" | "GLOSSARY_TERM_MISSED" | "INTEGRITY_REORDERED" | "PROVIDER_DEGRADED";
127
163
  /** A key flagged for human review, carrying every reason code that applies. */
128
164
  interface ReviewFlag {
165
+ /** Always "review": the flag exists only for flagged keys, so there is no "ok" member. */
129
166
  readonly status: "review";
167
+ /** Every {@link ReviewReasonCode} that applies to this key; never empty. */
130
168
  readonly reasons: readonly ReviewReasonCode[];
131
169
  }
132
170
  /** Result of a batch translation: per-key values, per-key integrity outcomes, and any notices. */
133
171
  interface TranslateResult {
134
- /** The translated value for each requested key. */
172
+ /**
173
+ * The translated value for each requested key. A key can be absent when the provider withheld it
174
+ * (see `PLACEHOLDER_UNSUPPORTED`) or never returned it, so callers must not assume one entry in
175
+ * equals one entry out.
176
+ */
135
177
  readonly values: ReadonlyMap<string, string>;
136
178
  /** The placeholder-integrity outcome for each key (source vs translated placeholder sets). */
137
179
  readonly integrity: ReadonlyMap<string, PlaceholderIntegrityResult$1>;
@@ -151,39 +193,20 @@ interface TranslateResult {
151
193
  }
152
194
  /**
153
195
  * The single contract every provider implements. It is narrow enough that a machine-translation API like
154
- * DeepL fits it directly, while LLM providers implement it by delegating to {@link runLlmTranslation}.
155
- * A new provider attaches by implementing this, then adding an entry to the `providerFactories` table
156
- * in `packages/sdk/src/config/provider-config.ts` alongside its member of the `providerConfigSchema`
157
- * union. That table is a mapped type over the union's id set, so a provider present in one but not the
158
- * other fails to compile.
196
+ * DeepL fits it directly, while LLM providers implement it by delegating to the shared
197
+ * `runLlmTranslation` layer. A new provider attaches by implementing this, then adding an entry to the
198
+ * `providerFactories` table in `packages/sdk/src/config/provider-config.ts` alongside its member of the
199
+ * provider config union. That table is a mapped type over the union's id set, so a provider present in
200
+ * one but not the other fails to compile.
159
201
  *
160
202
  * Implementer invariants:
161
203
  * - Translatable strings are UNTRUSTED. They travel only as data to the provider; never splice them into
162
204
  * instruction text, and never act on instructions a value appears to contain.
163
205
  * - Read the API key ONLY from the environment (inside the SDK client). The request, config, and this
164
206
  * interface never carry a key.
165
- * - Fail with a secret-free {@link ProviderError}: never bind, log, or re-throw raw SDK error text (it can
166
- * carry a key or request headers). Validate the request at the boundary with `validateRequest` so the
167
- * integrity check can never be skipped.
168
- *
169
- * @example
170
- * ```ts
171
- * // A machine-translation provider implements translateBatch directly (the DeepL shape).
172
- * function createMyMtProvider(client: MyClient): TranslationProvider {
173
- * return {
174
- * id: "my-mt",
175
- * kind: "machine-translation",
176
- * supportsGlossary: false,
177
- * async translateBatch(request) {
178
- * const data = validateRequest(request); // throws INVALID_REQUEST on a bad request
179
- * const texts = data.entries.map((e) => e.value);
180
- * const out = await client.translate(texts, data.targetLocale); // SDK reads MY_API_KEY from env
181
- * // map out -> values, run the integrity check, return { values, integrity }
182
- * return buildResult(data, out, request.extractPlaceholders);
183
- * },
184
- * };
185
- * }
186
- * ```
207
+ * - Fail with a secret-free `ProviderError`: never bind, log, or re-throw raw SDK error text (it can
208
+ * carry a key or request headers). Validate the request at the boundary so the integrity check can
209
+ * never be skipped.
187
210
  */
188
211
  interface TranslationProvider {
189
212
  /** A stable identifier for this provider (for example, "anthropic", "deepl"). */
@@ -197,16 +220,18 @@ interface TranslationProvider {
197
220
  *
198
221
  * @param request - The provider-neutral batch request (no prompt, model, or key).
199
222
  * @returns The per-key translated values and per-key placeholder-integrity outcomes.
200
- * @throws {@link ProviderError}, secret-free, with the code for the failure (the concrete codes are
201
- * the implementation's; see each provider factory).
223
+ * @throws `ProviderError`, secret-free, with the code for the failure (the concrete codes are
224
+ * the implementation's; see each provider factory). An aborted {@link TranslateRequest.signal}
225
+ * instead rejects with the abort itself, unwrapped.
202
226
  */
203
227
  translateBatch(request: TranslateRequest): Promise<TranslateResult>;
204
228
  }
205
229
 
206
230
  /**
207
- * The Anthropic authoring model type, sourced from the SDK's own model union. It is an
208
- * open union (known literals plus `string & {}`), so unknown or newly released model IDs
209
- * are still accepted. This drives editor completions only; the runtime schema validates.
231
+ * The Anthropic authoring model type, sourced from the SDK's own model union so verbatra
232
+ * restates no model IDs. It is an open union (known literals plus `string & {}`), so unknown
233
+ * or newly released model IDs are still accepted. Type-only: it informs editor completions
234
+ * and is never validated at runtime, where the schema stays `z.string().min(1)`.
210
235
  */
211
236
  type AnthropicModel = Anthropic.Messages.Model;
212
237
 
@@ -219,13 +244,24 @@ type AnthropicModel = Anthropic.Messages.Model;
219
244
  type GeminiModel = Interactions.Model;
220
245
 
221
246
  /**
222
- * The OpenAI authoring model type, sourced from the OpenAI SDK's published chat-model
223
- * union. It drives editor completions only; it is never read at runtime, and the
224
- * runtime schema stays `z.string().min(1)`.
247
+ * The OpenAI authoring model type, sourced from the OpenAI SDK's published chat-model union
248
+ * so verbatra restates no model IDs. Type-only: it drives editor completions and is never
249
+ * read at runtime, where the schema stays `z.string().min(1)`.
225
250
  */
226
251
  type OpenAiModel = OpenAI.ChatModel;
227
252
 
228
- /** Zod schema accepting exactly one of {@link SUPPORTED_FORMATS}. */
253
+ /**
254
+ * Zod schema accepting exactly one of the eight source formats verbatra can read and write. Each
255
+ * member names the ecosystem it round-trips, not just a file extension, because several of them share
256
+ * one: `i18next-json` is i18next's nested JSON with its plural key suffixes, `vue-i18n-json` is Vue
257
+ * I18n's JSON with its pipe-separated plural values, `next-intl-json` is next-intl's ICU-message JSON,
258
+ * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining four are `xliff` (the XLIFF
259
+ * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle), and
260
+ * `properties` (Java and Spring `.properties` files).
261
+ *
262
+ * The set is closed: a format outside it cannot be represented, so adding one means adding a member
263
+ * here and an adapter that claims it.
264
+ */
229
265
  declare const supportedFormatSchema$1: z.ZodEnum<{
230
266
  "i18next-json": "i18next-json";
231
267
  "vue-i18n-json": "vue-i18n-json";
@@ -236,92 +272,110 @@ declare const supportedFormatSchema$1: z.ZodEnum<{
236
272
  arb: "arb";
237
273
  properties: "properties";
238
274
  }>;
239
- /** One of the supported source formats; a member of {@link SUPPORTED_FORMATS}. */
275
+ /** One of the eight supported source formats; the inferred type of {@link supportedFormatSchema}. */
240
276
  type SupportedFormat$1 = z.infer<typeof supportedFormatSchema$1>;
241
277
 
242
- /** Outcome of a bounded read: the content, or why it could not be read in bounds. */
278
+ /**
279
+ * The outcome of a size-bounded text read. A file that is absent or larger than the caller's limit
280
+ * is reported as a state rather than thrown, so a caller can treat "no file yet" as normal and an
281
+ * oversized file as a distinct, actionable condition.
282
+ */
243
283
  type BoundedFileRead = {
284
+ /** The file was read in full. */
244
285
  readonly kind: "ok";
286
+ /** The file's content, decoded as UTF-8. */
245
287
  readonly content: string;
246
288
  } | {
289
+ /** No readable file exists at the path. */
247
290
  readonly kind: "missing";
248
291
  } | {
292
+ /** The file exists but exceeds the requested byte limit, so nothing was read. */
249
293
  readonly kind: "too-large";
250
294
  };
251
- /** Outcome of a bounded binary read: the bytes, or why they could not be read in bounds. */
295
+ /**
296
+ * The outcome of a size-bounded binary read, used for interchange workbooks. Mirrors
297
+ * {@link BoundedFileRead} but yields raw bytes rather than decoded text.
298
+ */
252
299
  type BoundedBytesRead = {
300
+ /** The file was read in full. */
253
301
  readonly kind: "ok";
302
+ /** The file's raw bytes. */
254
303
  readonly bytes: Uint8Array;
255
304
  } | {
305
+ /** No readable file exists at the path. */
256
306
  readonly kind: "missing";
257
307
  } | {
308
+ /** The file exists but exceeds the requested byte limit, so nothing was read. */
258
309
  readonly kind: "too-large";
259
310
  };
260
311
  /**
261
- * The minimal file-system surface the SDK needs. Reads are bounded and writes are atomic. Injectable so
262
- * tests stay deterministic; the format adapters do their own file IO and bypass this seam.
312
+ * The file-system port the SDK's own I/O goes through. Supplying your own implementation as a
313
+ * `deps.fs` option redirects that I/O, which is how the SDK's tests avoid touching disk and how an
314
+ * embedding application can back part of a project with something other than a local disk.
315
+ *
316
+ * The seam carries every file the SDK touches: the run-status file, the lock-file, the config
317
+ * glossary, workbook and interchange I/O, and the locale files themselves, which the adapters read
318
+ * and write through a port built from this one. The single exception is a caller-supplied
319
+ * `deps.adapterRegistry`: those adapters were constructed by the caller, so their file access is
320
+ * whatever the caller wired into them, and supplying both means the caller owns that wiring.
321
+ *
322
+ * Reads are size-bounded by contract so that a hostile or accidentally huge file cannot exhaust
323
+ * memory. Writes are expected to be atomic: the default implementation writes to a temporary file
324
+ * and renames it into place, so a crash mid-write never leaves a half-written file behind.
325
+ * Directory creation is the caller's job, so an implementation whose `writeFile` targets a real
326
+ * directory tree must also implement `mkdir`.
263
327
  */
264
328
  interface SdkFs {
265
- /** Whether a readable file exists at the path. */
329
+ /** Reports whether a readable file exists at the path. */
266
330
  fileExists(path: string): Promise<boolean>;
267
- /**
268
- * Read a file as UTF-8 through a single handle, bounded to maxBytes. TOCTOU-safe: the handle is
269
- * fstat'd and the read never advances past the sized length, so swapping in a larger file cannot
270
- * bypass the cap. A missing or unreadable path is "missing"; a file over the cap is "too-large".
271
- */
331
+ /** Reads a file as UTF-8 text, refusing to read more than `maxBytes`. */
272
332
  readFileBounded(path: string, maxBytes: number): Promise<BoundedFileRead>;
273
- /** Read a file as raw bytes with the same TOCTOU-safe, bounded discipline as {@link readFileBounded}. */
333
+ /** Reads a file as raw bytes, refusing to read more than `maxBytes`. */
274
334
  readBytesBounded(path: string, maxBytes: number): Promise<BoundedBytesRead>;
275
- /** Write atomically: a temp file in the same directory, then rename over the target. */
335
+ /** Writes UTF-8 text to the path, replacing any existing file atomically. */
276
336
  writeFile(path: string, data: string): Promise<void>;
277
- /** Write raw bytes atomically (temp file, then rename over the target). Used for the workbook. */
337
+ /** Writes raw bytes to the path, replacing any existing file atomically. */
278
338
  writeBytes(path: string, data: Uint8Array): Promise<void>;
279
339
  /**
280
- * Atomically create a file only if it does not already exist (O_EXCL semantics), creating any
281
- * missing parent directory first. Returns true if this call created the file, false if a file
282
- * was already present (no write performed). The backbone of {@link withLocaleWriteLock}'s
283
- * exclusive-create lock: the parent-directory creation lives here so that primitive never touches
284
- * `node:fs` directly and a fake `SdkFs` in tests never needs a real directory to exist.
340
+ * Creates a file only if it does not already exist, returning `false` when it does. This is the
341
+ * primitive behind the per-locale write lock, so it must be atomic against other processes.
285
342
  */
286
343
  createExclusive(path: string, data: string): Promise<boolean>;
287
- /** Delete a file if present; a no-op if it is already absent. */
344
+ /** Removes the file at the path, succeeding even when it is already absent. */
288
345
  deleteFile(path: string): Promise<void>;
289
- /**
290
- * Create a directory and every missing parent (mkdir -p); a no-op if it already exists.
291
- *
292
- * Optional so an `SdkFs` written against an earlier version stays valid, and because an in-memory
293
- * test double has no directories to create. Only the delimited export calls it, right before writing
294
- * one interchange file per locale into the directory the caller named.
295
- */
346
+ /** Creates a directory and any missing parents. Optional; omit it if the backing store has no directories. */
296
347
  mkdir?(path: string): Promise<void>;
297
348
  }
298
349
 
299
350
  /**
300
- * The content-addressed translation-memory (TM) cache: a local, gitignored, regenerable sidecar to
301
- * the lock file (`verbatra.cache.json`). It lets a translation whose source content is unchanged be
302
- * reused for free, even under a renamed key or across two keys with byte-identical source text,
303
- * instead of being re-sent to the provider.
351
+ * The on-disk translation memory, read from and written to `verbatra.cache.json` (see
352
+ * {@link CACHE_FILE_NAME}). A run consults it before calling the provider, so a string that was
353
+ * already translated under the same configuration is reused instead of paid for again.
304
354
  *
305
- * Nesting is `entries[fingerprint][targetLocale][contentHash] = value`. The fingerprint scopes every
306
- * entry to one translation context (provider, model, tone, glossary), so a tone or glossary change
307
- * never serves a stale value. Unlike the lock file, a corrupt or wrong-version cache degrades to an
308
- * empty cache and never fails a run: it is a cost optimization, not a correctness record.
355
+ * Entries are keyed by configuration fingerprint, then locale, then source-content hash. The
356
+ * fingerprint layer means that changing the provider, model, tone, or glossary does not silently
357
+ * reuse translations produced under the old settings; those entries simply stop matching.
309
358
  */
310
359
  interface TranslationMemory {
311
- /** The cache schema version; a value this build does not recognize degrades the whole file to empty. */
360
+ /** The cache schema version. A file at an unrecognized version is ignored rather than trusted. */
312
361
  readonly version: number;
313
- /** Cached values, nested fingerprint to target locale to source content hash to translated value. */
362
+ /** Cached values, nested as configuration fingerprint, then locale, then source-content hash. */
314
363
  readonly entries: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, string>>>>>>;
315
364
  }
316
365
 
317
- /** The cache file's name, a sibling of the lock file and obviously JSON. */
366
+ /**
367
+ * The file name of the project's translation memory, resolved against the run's working directory.
368
+ * Commit it to reuse translations across machines and CI runs, or add it to `.gitignore` to treat
369
+ * the cache as purely local. Deleting it is always safe: the next run simply repays for the strings
370
+ * it would have reused.
371
+ */
318
372
  declare const CACHE_FILE_NAME = "verbatra.cache.json";
319
373
 
320
374
  /**
321
- * The provider section of the config: a discriminated union over the provider id, reusing each
322
- * provider's own config schema. There is no key field anywhere in this union except
323
- * openai-compatible's optional `apiKeyEnvVar`, which names an environment variable rather than carrying
324
- * a key value; every provider still reads its actual key from the environment at construction.
375
+ * The zod schema for the `provider` block, discriminated on `id`. Each variant's options are
376
+ * validated strictly, so an option that belongs to a different provider is reported as an error
377
+ * rather than ignored. It is embedded in {@link verbatraConfigSchema} and produces
378
+ * {@link ProviderConfig}.
325
379
  */
326
380
  declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
327
381
  id: z.ZodLiteral<"anthropic">;
@@ -360,17 +414,41 @@ declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
360
414
  requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
361
415
  }, z.core.$strict>;
362
416
  }, z.core.$strip>], "id">;
363
- /** One validated provider section: the provider id plus that provider's own options. */
417
+ /**
418
+ * The `provider` block of a verbatra config: a discriminated union on `id`, so each provider
419
+ * carries exactly the options it supports and nothing else.
420
+ *
421
+ * No variant has a field for an API key. Keys are read from the environment by the provider itself
422
+ * (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`), which is what keeps
423
+ * them out of config files and out of version control. The `openai-compatible` variant may name a
424
+ * different environment variable through `apiKeyEnvVar`, but still never holds the key itself.
425
+ */
364
426
  type ProviderConfig = z.infer<typeof providerConfigSchema>;
365
- /** The closed set of configurable provider ids. */
427
+ /**
428
+ * The identifier of a supported translation provider: `anthropic`, `openai`, `gemini`, `deepl`, or
429
+ * `openai-compatible`. The last one targets a local or self-hosted server that speaks the OpenAI
430
+ * chat-completions API.
431
+ */
366
432
  type ProviderId = ProviderConfig["id"];
367
433
 
368
434
  /**
369
- * The verbatra project configuration. It carries no API key (the provider reads its key from the
370
- * environment), and unknown top-level keys are rejected so a stray secret cannot hide in it. Validated
371
- * with zod at the boundary regardless of where it was loaded from.
435
+ * The zod schema every verbatra config is validated against. {@link loadConfig} applies it, so most
436
+ * consumers never call it directly; reach for it when you build a config programmatically and want
437
+ * to validate it before handing it to {@link translate}, or when you surface config errors in your
438
+ * own tooling.
439
+ *
440
+ * The object is strict, so an unrecognized key is an error rather than being silently ignored: a
441
+ * typo in a config file is reported instead of quietly doing nothing. The one exception is the
442
+ * optional `$schema` key, accepted so a JSON or YAML config can point an editor at the JSON Schema
443
+ * document the package ships as `@verbatra/sdk/config-schema.json`.
444
+ *
445
+ * Beyond the per-field checks, two whole-config rules are enforced: `targetLocales` must not
446
+ * contain the source locale, and it must not contain two locales that differ only in case (they
447
+ * would collide on a case-insensitive file system). The `{locale}` token requirement is a
448
+ * per-field check on `files.pattern`.
372
449
  */
373
450
  declare const verbatraConfigSchema: z.ZodObject<{
451
+ $schema: z.ZodOptional<z.ZodString>;
374
452
  sourceLocale: z.ZodString;
375
453
  targetLocales: z.ZodArray<z.ZodString>;
376
454
  format: z.ZodEnum<{
@@ -444,30 +522,40 @@ declare const verbatraConfigSchema: z.ZodObject<{
444
522
  }>>;
445
523
  }, z.core.$strict>;
446
524
  /**
447
- * The as-authored (or as-parsed) shape of the verbatra configuration, straight from the schema: `glossary`
448
- * is still the union of an inline record or a file path. {@link defineConfig} and the authoring types are
449
- * built on this; `loadConfig` accepts it as input and produces the resolved {@link VerbatraConfig}.
525
+ * A config exactly as it is written in a `verbatra.config.ts` file, before the SDK resolves
526
+ * anything. Its `glossary` may still be a path string pointing at a JSON file.
527
+ *
528
+ * This is what {@link defineConfig} returns and what {@link verbatraConfigSchema} parses. Use
529
+ * {@link VerbatraConfig} for the resolved shape the flows actually consume.
450
530
  */
451
531
  type VerbatraConfigInput = z.infer<typeof verbatraConfigSchema>;
452
532
  /**
453
- * The verbatra configuration after `loadConfig` has resolved it: identical to {@link VerbatraConfigInput}
454
- * except `glossary`, which is always a plain record here. A glossary given as a file path is read,
455
- * parsed, and validated by the loader before a `VerbatraConfig` is produced, so every downstream
456
- * consumer (the translation flow, `watch`, the CLI) keeps receiving the same resolved shape it always
457
- * did.
533
+ * A fully resolved config, ready to pass to any SDK entry point. It differs from
534
+ * {@link VerbatraConfigInput} in one respect: `glossary` is always an in-memory term map, because
535
+ * {@link loadConfig} has already read and validated any glossary file the config pointed at.
536
+ *
537
+ * Every entry point takes this shape, so a caller that builds a config by hand rather than loading
538
+ * one from disk must supply the glossary already resolved.
458
539
  */
459
540
  type VerbatraConfig = Omit<VerbatraConfigInput, "glossary"> & {
541
+ /**
542
+ * Terms that must be translated a fixed way, already resolved to an in-memory map. A config that
543
+ * named a glossary file has had it read by {@link loadConfig} before it reaches here.
544
+ */
460
545
  glossary?: Readonly<Record<string, string>>;
461
546
  };
462
547
 
463
548
  /**
464
- * A provider's known model literals, with the open `string` arm stripped, so the authoring field
465
- * accepts only that provider's models. Static authoring aid only; the runtime schema stays a
466
- * non-empty string, so a brand-new model the installed SDK does not yet list is flagged but still
467
- * runs.
549
+ * Reduces a model union to its literal members, discarding the bare `string` member that some
550
+ * vendor SDKs include to stay open to unreleased models. Without this, `string` would absorb the
551
+ * literals and the editor would offer no completions at all.
468
552
  */
469
553
  type KnownModels<M extends string> = M extends string ? (string extends M ? never : M) : never;
470
- /** One provider's config variant with `options.model` narrowed to that provider's known literals. */
554
+ /**
555
+ * One provider's config variant with its `model` field narrowed from `string` to that vendor's
556
+ * known model IDs, which is what turns model names into an autocompleted, checked union while
557
+ * authoring.
558
+ */
471
559
  type AuthoringVariant<Id extends ProviderId, M extends string> = Extract<ProviderConfig, {
472
560
  id: Id;
473
561
  }> extends infer Variant ? Variant extends {
@@ -480,219 +568,412 @@ type AuthoringVariant<Id extends ProviderId, M extends string> = Extract<Provide
480
568
  };
481
569
  } : never : never;
482
570
  /**
483
- * The authoring view of one provider variant, keyed by id. LLM providers with a known SDK model
484
- * union restrict `options.model` to that provider's literals. DeepL has no model field and
485
- * openai-compatible's model is whatever the local server exposes (no known-model list to restrict
486
- * against), so both are carried through unchanged.
571
+ * Maps each provider ID to its authoring-time config variant. The three language-model providers
572
+ * get narrowed model unions; DeepL has no model to narrow, and `openai-compatible` deliberately
573
+ * keeps a free-form model string because the endpoint is a local or self-hosted server whose model
574
+ * list the SDK cannot know ahead of time.
487
575
  */
488
576
  type AuthoringProviderVariant = {
577
+ /** Anthropic, with `model` narrowed to the Claude model IDs. */
489
578
  anthropic: AuthoringVariant<"anthropic", AnthropicModel>;
579
+ /** OpenAI, with `model` narrowed to the OpenAI chat model IDs. */
490
580
  openai: AuthoringVariant<"openai", OpenAiModel>;
581
+ /** Gemini, with `model` narrowed to the Gemini model IDs. */
491
582
  gemini: AuthoringVariant<"gemini", GeminiModel>;
583
+ /** DeepL, which takes no model because it is a machine-translation API rather than a language model. */
492
584
  deepl: Extract<ProviderConfig, {
493
585
  id: "deepl";
494
586
  }>;
587
+ /** A local or self-hosted OpenAI-compatible endpoint, whose model stays a free-form string. */
495
588
  "openai-compatible": Extract<ProviderConfig, {
496
589
  id: "openai-compatible";
497
590
  }>;
498
591
  };
499
592
  /**
500
- * The authoring view of the whole config for a provider id: a {@link VerbatraConfigInput} whose
501
- * `provider` is that id's single authoring variant, so `options.model` offers only that provider's
502
- * models. `glossary` stays the as-authored union (inline record or file path); resolution happens only
503
- * in `loadConfig`. When `TId` defaults to `ProviderId`, `provider` is the full authoring union. Every
504
- * value here is assignable to {@link VerbatraConfigInput}, since a model literal is a subtype of
505
- * `string`.
593
+ * A config as authored for one specific provider: the ordinary {@link VerbatraConfigInput} shape,
594
+ * but with `provider` narrowed to that provider's variant so its model IDs autocomplete and a model
595
+ * belonging to another vendor is a type error.
596
+ *
597
+ * This is the parameter type of each {@link defineConfig} overload.
506
598
  */
507
599
  type AuthoringConfigFor<TId extends ProviderId = ProviderId> = Omit<VerbatraConfigInput, "provider"> & {
600
+ /** The provider block, narrowed to the variant for `TId`. */
508
601
  provider: AuthoringProviderVariant[TId];
509
602
  };
510
603
  /**
511
- * The authoring view of the whole config across every provider (the `TId = ProviderId`
512
- * case of {@link AuthoringConfigFor}): identical to {@link VerbatraConfigInput} except that
513
- * `provider` offers per-provider model completions.
604
+ * A config authored for any supported provider: {@link AuthoringConfigFor} left un-narrowed, which
605
+ * is the type of the fallback {@link defineConfig} overload.
514
606
  */
515
607
  type AuthoringConfig = AuthoringConfigFor;
516
608
 
517
609
  /**
518
- * Identity helper for authoring a typed `verbatra.config.ts`. It returns its argument unchanged; its
519
- * only purpose is full type inference and editor autocomplete on the config object, including
520
- * completion of the selected provider's known model literals.
610
+ * Types a `verbatra.config.ts` while you author it. The call is a pure identity function at
611
+ * runtime: its only job is to give your editor the type of the config object so that keys are
612
+ * completed, unknown keys are rejected, and the model name is checked against the models the
613
+ * selected provider actually offers.
521
614
  *
522
- * The model restriction is a static authoring constraint only: `loadConfig` validates `model` as a
523
- * non-empty string, so a model the installed provider SDK does not yet list is flagged in the editor
524
- * but still runs. Likewise, `glossary` accepts either an inline record or a file path here; `loadConfig`
525
- * resolves a path to a record before the translation flow ever sees it.
615
+ * This overload narrows to the Anthropic provider, so `provider.options.model` completes to the
616
+ * Claude model IDs and rejects a model belonging to another vendor.
526
617
  *
527
- * @param config - The verbatra configuration object.
528
- * @returns The same config, typed as {@link VerbatraConfigInput}.
618
+ * Validation at load time is a separate step: {@link loadConfig} still parses the file against
619
+ * {@link verbatraConfigSchema}, so a config that type-checks can still fail validation.
620
+ *
621
+ * @param config - The config object, narrowed to the Anthropic provider.
622
+ * @returns The same object, typed as {@link VerbatraConfigInput}.
623
+ *
624
+ * @example
625
+ * ```ts
626
+ * import { defineConfig } from "@verbatra/sdk";
627
+ *
628
+ * export default defineConfig({
629
+ * sourceLocale: "en",
630
+ * targetLocales: ["de", "fr"],
631
+ * format: "i18next-json",
632
+ * files: { pattern: "locales/{locale}/common.json" },
633
+ * provider: {
634
+ * id: "anthropic",
635
+ * options: { model: "claude-sonnet-4-6", maxTokens: 4096 },
636
+ * },
637
+ * });
638
+ * ```
529
639
  */
530
640
  declare function defineConfig(config: AuthoringConfigFor<"anthropic">): VerbatraConfigInput;
641
+ /**
642
+ * Types a `verbatra.config.ts` narrowed to the OpenAI provider, so `provider.options.model`
643
+ * completes to the OpenAI chat model IDs.
644
+ *
645
+ * @param config - The config object, narrowed to the OpenAI provider.
646
+ * @returns The same object, typed as {@link VerbatraConfigInput}.
647
+ */
531
648
  declare function defineConfig(config: AuthoringConfigFor<"openai">): VerbatraConfigInput;
649
+ /**
650
+ * Types a `verbatra.config.ts` narrowed to the Gemini provider, so `provider.options.model`
651
+ * completes to the Gemini model IDs.
652
+ *
653
+ * @param config - The config object, narrowed to the Gemini provider.
654
+ * @returns The same object, typed as {@link VerbatraConfigInput}.
655
+ */
532
656
  declare function defineConfig(config: AuthoringConfigFor<"gemini">): VerbatraConfigInput;
657
+ /**
658
+ * Types a `verbatra.config.ts` narrowed to the DeepL provider. DeepL is a machine-translation API
659
+ * rather than a language model, so its options carry an optional glossary ID instead of a model.
660
+ *
661
+ * @param config - The config object, narrowed to the DeepL provider.
662
+ * @returns The same object, typed as {@link VerbatraConfigInput}.
663
+ */
533
664
  declare function defineConfig(config: AuthoringConfigFor<"deepl">): VerbatraConfigInput;
665
+ /**
666
+ * Types a `verbatra.config.ts` for any supported provider. This is the fallback overload, and the
667
+ * one that serves `openai-compatible`, where the model is a free-form string because the endpoint
668
+ * is a local or self-hosted server whose model list the SDK cannot know.
669
+ *
670
+ * @param config - The config object, for any provider.
671
+ * @returns The same object, typed as {@link VerbatraConfigInput}.
672
+ */
534
673
  declare function defineConfig(config: AuthoringConfig): VerbatraConfigInput;
535
674
 
536
- /** Where a config's resolved glossary came from: absent, inline, or a file at the recorded path. */
675
+ /**
676
+ * Where a loaded config's glossary came from, reported by {@link loadConfigWithMeta}. The resolved
677
+ * {@link VerbatraConfig} holds only the final term map, so this is the one place a tool can tell an
678
+ * inline glossary from one read out of a file, and name that file.
679
+ */
537
680
  type GlossaryProvenance = {
681
+ /** The config declared no glossary. */
538
682
  readonly source: "none";
539
683
  } | {
684
+ /** The glossary was written inline in the config as a term map. */
540
685
  readonly source: "inline";
541
686
  } | {
687
+ /** The glossary was read from a JSON file the config pointed at. */
542
688
  readonly source: "file";
689
+ /** The absolute path the glossary was read from. */
543
690
  readonly path: string;
544
691
  };
545
692
 
546
- /** Where {@link loadConfig} should obtain the configuration. The three sources are alternatives. */
693
+ /** Input for {@link readGlossaryFile} and {@link updateGlossaryTerm}. */
694
+ interface GlossaryFileInput {
695
+ /**
696
+ * Where the loaded config's glossary came from, as reported by {@link loadConfigWithMeta}. Only a
697
+ * `file` provenance is accepted; it also carries the path, which is why neither function takes one.
698
+ */
699
+ readonly glossary: GlossaryProvenance;
700
+ }
701
+ /** Input for {@link updateGlossaryTerm}. */
702
+ interface UpdateGlossaryTermInput extends GlossaryFileInput {
703
+ /** Directory the write lock is taken under. Defaults to the process working directory. */
704
+ readonly cwd?: string;
705
+ /** The source term to add, replace, or remove. Must not be blank. */
706
+ readonly term: string;
707
+ /** The translation to store for the term, or `null` to remove the term. Must not be blank. */
708
+ readonly translation: string | null;
709
+ }
710
+ /** Injectable dependencies for {@link readGlossaryFile} and {@link updateGlossaryTerm}. */
711
+ interface GlossaryFileDeps {
712
+ /** File-system port. Defaults to the real file system. */
713
+ readonly fs?: SdkFs;
714
+ }
715
+ /**
716
+ * Reads a file-backed glossary fresh from disk, under the same validation {@link loadConfig} applies
717
+ * when it resolves a glossary path. Use it when a long-running tool has to show the glossary as it
718
+ * is now rather than as it was when the config was loaded.
719
+ *
720
+ * The file to read comes from the provenance itself, so there is no way to point this at a file the
721
+ * loaded config does not name.
722
+ *
723
+ * @param input - The glossary provenance from a loaded config.
724
+ * @param deps - Optional file-system override.
725
+ * @returns The glossary as a flat term map.
726
+ *
727
+ * @throws {@link SdkError} `GLOSSARY_NOT_FILE_BACKED`: the config's glossary is inline or absent, so
728
+ * there is no file to read.
729
+ * @throws {@link SdkError} `CONFIG_INVALID`: the glossary file is missing, oversized, not UTF-8, not
730
+ * valid JSON, or not a flat object of string keys to string values.
731
+ */
732
+ declare function readGlossaryFile(input: GlossaryFileInput, deps?: GlossaryFileDeps): Promise<Readonly<Record<string, string>>>;
733
+ /**
734
+ * Adds, replaces, or removes one term in a file-backed glossary, rewriting the JSON file in place.
735
+ * This is the write path behind a glossary editor: one term changes and the rest of the file keeps
736
+ * its order and its indentation.
737
+ *
738
+ * Only a file-backed glossary can be edited. An inline glossary lives inside the config module,
739
+ * which is executable code this function will not rewrite, and no glossary at all has no file to
740
+ * write to; both are refused rather than converted.
741
+ *
742
+ * The whole read-modify-write runs under a project-wide glossary lock, so two concurrent edits are
743
+ * serialized rather than interleaved, and the file itself is replaced atomically. The result is
744
+ * validated to stay within the same size limit {@link loadConfig} enforces when it reads a glossary
745
+ * file back, so an accepted write is always re-readable.
746
+ *
747
+ * @param input - The glossary provenance, the term, and its new translation or `null` to remove it.
748
+ * @param deps - Optional file-system override.
749
+ * @returns The glossary as it now stands on disk.
750
+ *
751
+ * @throws {@link SdkError} `GLOSSARY_NOT_FILE_BACKED`: the config's glossary is inline or absent, so
752
+ * there is no file to write.
753
+ * @throws {@link SdkError} `CONFIG_INVALID`: the term or its translation is blank, the glossary file
754
+ * on disk is missing, oversized, not UTF-8, not valid JSON, or not a flat object of string keys to
755
+ * string values, or the updated glossary would exceed the maximum glossary file size.
756
+ * @throws {@link SdkError} `LOCK_CONTENDED`: the project's glossary write lock could not be acquired
757
+ * before the timeout elapsed.
758
+ * @throws {@link SdkError} `GLOSSARY_UNWRITABLE`: the glossary file could not be written.
759
+ *
760
+ * @example
761
+ * ```ts
762
+ * import { loadConfigWithMeta, updateGlossaryTerm } from "@verbatra/sdk";
763
+ *
764
+ * const loaded = await loadConfigWithMeta({ cwd: process.cwd() });
765
+ * const glossary = await updateGlossaryTerm({
766
+ * glossary: loaded.glossary,
767
+ * cwd: process.cwd(),
768
+ * term: "Verbatra",
769
+ * translation: "Verbatra",
770
+ * });
771
+ *
772
+ * console.log(Object.keys(glossary).length);
773
+ * ```
774
+ */
775
+ declare function updateGlossaryTerm(input: UpdateGlossaryTermInput, deps?: GlossaryFileDeps): Promise<Readonly<Record<string, string>>>;
776
+
777
+ /** Options for {@link loadConfig} and {@link loadConfigWithMeta}. */
547
778
  interface LoadConfigOptions {
548
- /** Directory to start the search from. Defaults to the current working directory. */
779
+ /** Directory to search from, and the base for relative paths. Defaults to the process working directory. */
549
780
  readonly cwd?: string;
550
781
  /**
551
- * A pre-resolved config object (e.g. one passed in code) to validate instead of
552
- * searching the file system. Still validated with zod, exactly like a loaded file.
782
+ * A config object to validate directly instead of reading any file. Takes precedence over
783
+ * `configPath` and over searching. Useful for embedding verbatra in a tool that already holds the
784
+ * configuration in memory.
553
785
  */
554
786
  readonly configOverride?: unknown;
555
- /**
556
- * An explicit config file to load instead of searching. A relative path resolves against `cwd`; an
557
- * absolute path is used as given. Parsed and zod-validated exactly like a searched file: a missing
558
- * file is `CONFIG_NOT_FOUND`, a present but invalid one is `CONFIG_INVALID`. Takes precedence over
559
- * search, but `configOverride` takes precedence over it.
560
- */
787
+ /** An explicit config file to load, bypassing the search. A missing file is an error rather than a fallback to search. */
561
788
  readonly configPath?: string;
562
- /** The file-system seam a glossary file path is read through. Defaults to {@link defaultFs}. */
789
+ /** File-system port used to read the glossary file. Defaults to the real file system. */
563
790
  readonly fs?: SdkFs;
564
791
  }
565
- /** Where the loaded config came from: an in-memory override, one explicit file, or a search result. */
792
+ /**
793
+ * Where a loaded config came from, reported by {@link loadConfigWithMeta}. A tool can use it to
794
+ * show which file is in effect, or to tell a real file apart from an in-memory override.
795
+ */
566
796
  type ConfigSource = {
797
+ /**
798
+ * `search` means cosmiconfig found the file by walking up from the working directory;
799
+ * `explicit` means the caller named it through `configPath`.
800
+ */
567
801
  readonly kind: "search" | "explicit";
802
+ /** The absolute path of the config file that was loaded. */
568
803
  readonly filepath: string;
569
804
  } | {
805
+ /** The config came from `configOverride`, so no file was read. */
570
806
  readonly kind: "override";
571
807
  };
572
- /**
573
- * The result of {@link loadConfigWithMeta}: the resolved config, where it was loaded from, and where its
574
- * glossary came from. `filepath` (when present) and a `glossary` file path are always absolute.
575
- */
808
+ /** A validated config together with the provenance of the config itself and of its glossary. */
576
809
  interface LoadedConfig {
810
+ /** The validated, fully resolved config, with any glossary file already read into a term map. */
577
811
  readonly config: VerbatraConfig;
812
+ /** Where the config itself came from. */
578
813
  readonly source: ConfigSource;
814
+ /** Where the glossary came from, if the config declared one. */
579
815
  readonly glossary: GlossaryProvenance;
580
816
  }
581
817
  /**
582
- * Load and validate the verbatra configuration, returning provenance alongside it: which source it was
583
- * loaded from ({@link ConfigSource}), and whether its glossary is absent, inline, or resolved from a
584
- * file ({@link GlossaryProvenance}). {@link loadConfig} is a thin wrapper that returns only the
585
- * `config` field; use this directly when the provenance is needed (for example, to display it or to
586
- * re-resolve a glossary file relative to the same base directory).
818
+ * Loads and validates the project config, and additionally reports where it came from and where its
819
+ * glossary came from. Use this over {@link loadConfig} when a tool needs to show the user which
820
+ * config file is in effect, or to distinguish an inline glossary from a glossary file.
821
+ *
822
+ * Resolution order is: an explicit `configOverride`, then an explicit `configPath`, then a
823
+ * cosmiconfig search upward from `cwd` across `verbatra.config.ts`, the `.verbatrarc` family, and a
824
+ * `verbatra` property in `package.json`. A `configPath` that does not exist is an error rather than
825
+ * a fallback to searching, so a typo in a path never silently loads a different project's config.
826
+ *
827
+ * A glossary given as a path is read and validated here, so the returned config always carries a
828
+ * resolved term map.
829
+ *
830
+ * @param options - Where and how to look for the config.
831
+ * @returns The validated config with its config-source and glossary provenance.
587
832
  *
588
- * @param options - Where/what to load: `cwd`, an in-memory `configOverride`, an explicit `configPath`,
589
- * or a `fs` seam override.
590
- * @returns The resolved {@link VerbatraConfig}, its {@link ConfigSource}, and its {@link GlossaryProvenance}.
591
- * @throws {@link SdkError} `CONFIG_NOT_FOUND`: no config was found by search, or the explicit `configPath`
592
- * does not exist.
593
- * @throws {@link SdkError} `CONFIG_INVALID`: a config was found but is unparseable or fails validation, or
594
- * its glossary file path could not be read as a valid, UTF-8, flat string record (a raw error never
595
- * escapes).
833
+ * @throws {@link SdkError} `CONFIG_NOT_FOUND`: no config was found by search, or the explicit
834
+ * `configPath` does not exist.
835
+ * @throws {@link SdkError} `CONFIG_INVALID`: the config could not be loaded or fails validation, or
836
+ * its glossary file is missing, oversized, not UTF-8, not valid JSON, or not a flat string map.
596
837
  */
597
838
  declare function loadConfigWithMeta(options?: LoadConfigOptions): Promise<LoadedConfig>;
598
839
  /**
599
- * Load and validate the verbatra configuration. Supports a code-defined
600
- * verbatra.config.ts and file-based configs (.verbatrarc.json/.yaml, package.json
601
- * property) through cosmiconfig + cosmiconfig-typescript-loader. Multiple sources ->
602
- * first-found-wins by cosmiconfig precedence. Any failure is a structured SdkError;
603
- * a raw zod error is never thrown upward and an unvalidated config never proceeds.
604
- *
605
- * Precedence: `configOverride` (validate in-memory) > `configPath` (load one explicit file) > search.
606
- *
607
- * A `glossary` given as a file path is read, parsed, and validated at load time (see
608
- * {@link loadConfigWithMeta} for its provenance); a relative path resolves against the loaded config
609
- * file's directory, or against `cwd` for a `configOverride`. Watch mode does not re-resolve a glossary
610
- * file on later edits; restart to pick up a change.
611
- *
612
- * @param options - Where/what to load: `cwd`, an in-memory `configOverride`, or an explicit `configPath`.
613
- * @returns The validated {@link VerbatraConfig}.
614
- * @throws {@link SdkError} `CONFIG_NOT_FOUND`: no config was found by search, or the explicit `configPath`
615
- * does not exist.
616
- * @throws {@link SdkError} `CONFIG_INVALID`: a config was found but is unparseable or fails validation, or
617
- * its glossary file path could not be resolved (a raw error never escapes).
840
+ * Loads and validates the project config. This is the normal starting point for every SDK flow:
841
+ * pass the result to {@link translate}, {@link check}, {@link diff}, or any other entry point.
842
+ *
843
+ * Resolution order is an explicit `configOverride`, then an explicit `configPath`, then a
844
+ * cosmiconfig search upward from `cwd`. A glossary declared as a file path is read and validated
845
+ * here, so the returned {@link VerbatraConfig} always carries a resolved term map.
846
+ *
847
+ * Reach for {@link loadConfigWithMeta} when you also need to know which file was loaded.
848
+ *
849
+ * @param options - Where and how to look for the config.
850
+ * @returns The validated, fully resolved config.
851
+ *
852
+ * @throws {@link SdkError} `CONFIG_NOT_FOUND`: no config was found by search, or the explicit
853
+ * `configPath` does not exist.
854
+ * @throws {@link SdkError} `CONFIG_INVALID`: the config could not be loaded or fails validation, or
855
+ * its glossary file is missing, oversized, not UTF-8, not valid JSON, or not a flat string map.
856
+ *
618
857
  * @example
619
858
  * ```ts
620
859
  * import { loadConfig, translate } from "@verbatra/sdk";
621
860
  *
622
- * // Search upward from the cwd (verbatra.config.ts, .verbatrarc.json, or a package.json "verbatra" key):
623
861
  * const config = await loadConfig();
624
- * // Or load one explicit file (relative resolves against cwd; absolute as given):
625
- * // const config = await loadConfig({ configPath: "verbatra.config.ts" });
626
- *
627
862
  * const summary = await translate({ config });
863
+ * console.log(`${summary.succeeded.length} locales up to date`);
628
864
  * ```
629
865
  */
630
866
  declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig>;
631
867
 
632
868
  /**
633
- * Structured, secret-free error codes for the SDK boundaries. A key never appears in
634
- * any message: provider/adapter/core errors are already secret-free, and the SDK never
635
- * reads or holds a key. Each names a distinct boundary:
869
+ * The stable, structured failure codes the SDK throws. Branch on {@link SdkError.code} rather than
870
+ * on a message; the messages are written for humans and are not part of the contract.
636
871
  *
637
- * - `CONFIG_NOT_FOUND`: no config was found by search, or an explicit `configPath` does not exist
638
- * (thrown by `loadConfig`).
872
+ * A code never carries a secret. API keys are read from the environment by the providers alone, so
873
+ * the SDK never holds one, and provider, adapter, and core errors are secret-free before the SDK
874
+ * wraps them.
875
+ *
876
+ * - `CONFIG_NOT_FOUND`: no config file was found by search, or an explicit `configPath` does not
877
+ * exist. Thrown by {@link loadConfig} and {@link loadConfigWithMeta}. {@link doctor} narrows it
878
+ * to the explicit-path case: a config that is only absent from the search is reported as a failed
879
+ * check instead, since reporting that is the command's job.
639
880
  * - `CONFIG_INVALID`: a config was found but is unparseable or fails validation, or its glossary
640
- * file path could not be resolved (thrown by `loadConfig`).
641
- * - `UNKNOWN_FORMAT`: no adapter is registered for the configured format (thrown by every entry
642
- * point that selects an adapter, before any file is read).
643
- * - `UNKNOWN_LOCALE`: a requested locale is not among the configured target locales (thrown via
644
- * the shared locale selection by `check`, `diff`, `keyIntegrity`, `lockState`, `exportWorkbook`,
645
- * `keyValue`, `editEntry`, and `retranslateEntry`).
646
- * - `UNKNOWN_KEY`: a requested key is not among the source resource's own keys (thrown by
647
- * `keyValue`, `editEntry`, and `retranslateEntry`).
648
- * - `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw; wraps the provider's own error,
649
- * including a missing `*_API_KEY` reported as `MISSING_API_KEY` (thrown by non-dry-run
650
- * `translate` and by `retranslateEntry`).
651
- * - `SOURCE_UNREADABLE`: the source locale file is absent (thrown by every entry point that reads
652
- * the source, and by `watch` at startup).
653
- * - `SOURCE_INVALID`: the source locale file could not be read or parsed; wraps the adapter read
654
- * error (thrown by every entry point that reads the source).
655
- * - `LOCK_FILE_INVALID`: the lock-file is present but corrupt, oversized, or at an unsupported
656
- * version (thrown wherever the lock-file is read or updated: `translate`, `check`, `diff`,
657
- * `keyIntegrity`, `lockState`, `loadLockFile`, `exportWorkbook`, `importWorkbook`, `editEntry`,
658
- * and `retranslateEntry`).
659
- * - `LOCK_CONTENDED`: a locale's write lock (`withLocaleWriteLock`) could not be acquired before
660
- * its timeout elapsed, because another process is holding it (or an orphaned lock file was left
661
- * behind by a killed process); the message names the lock file's path (thrown by `translate`,
662
- * `importWorkbook`, `editEntry`, and `retranslateEntry`).
881
+ * file could not be resolved or parsed. Thrown by {@link loadConfig} and
882
+ * {@link loadConfigWithMeta}, by {@link readGlossaryFile}, and by {@link updateGlossaryTerm},
883
+ * which additionally throws it for a blank term or translation and for an edit whose result would
884
+ * exceed the glossary file size limit. {@link importWorkbook} does not throw it: when a handoff
885
+ * sheet or file names a locale that is not a configured target locale, it records this code on
886
+ * that locale's {@link LocaleSummary} instead.
887
+ * - `UNKNOWN_FORMAT`: no adapter is registered for the configured format. Thrown by every entry
888
+ * point that selects an adapter, before any file is read. {@link doctor} is the exception: it
889
+ * reports an unresolvable format as a failed `format-adapter` check instead, since reporting that
890
+ * is the command's job.
891
+ * - `UNKNOWN_LOCALE`: a requested locale is not among the configured target locales. Thrown through
892
+ * the shared locale selection by {@link translate}, {@link watch}, {@link check}, {@link diff},
893
+ * {@link keyIntegrity}, {@link lockState}, {@link exportWorkbook}, {@link keyValue},
894
+ * {@link editEntry}, and {@link retranslateEntry}. {@link translate} throws it before anything is
895
+ * read or spent, and {@link watch} once at startup, before any watching begins.
896
+ * - `UNKNOWN_KEY`: the requested key is not present in the source resource. Thrown by
897
+ * {@link keyValue}, {@link editEntry}, and {@link retranslateEntry}.
898
+ * - `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw. Wraps the provider's own error,
899
+ * including a missing `*_API_KEY` environment variable. Thrown by a non-dry-run
900
+ * {@link translate} and by {@link retranslateEntry}.
901
+ * - `SOURCE_UNREADABLE`: the source locale file is absent. Thrown by every entry point that reads
902
+ * the source, including {@link importWorkbook}, and by {@link watch} at startup.
903
+ * {@link importWorkbook} additionally throws it when the handoff file itself is missing.
904
+ * - `SOURCE_INVALID`: the source locale file, or an interchange file, could not be parsed. Wraps
905
+ * the adapter or reader error.
906
+ * - `LOCK_FILE_INVALID`: the lock-file exists but is corrupt, oversized, or at an unsupported
907
+ * version. Thrown wherever the lock-file is read or updated: {@link translate}, {@link check},
908
+ * {@link diff}, {@link keyIntegrity}, {@link lockState}, {@link loadLockFile},
909
+ * {@link exportWorkbook}, {@link importWorkbook}, {@link editEntry}, and
910
+ * {@link retranslateEntry}.
911
+ * - `LOCK_CONTENDED`: a write lock could not be acquired before its timeout elapsed, because
912
+ * another process holds it or a killed process left the lock file behind. The message
913
+ * names the lock file's path. Thrown by {@link editEntry} and {@link retranslateEntry}, which
914
+ * act on one locale, and by {@link updateGlossaryTerm}, which takes the project's glossary lock.
915
+ * {@link translate} and {@link importWorkbook} do not throw it: they record it
916
+ * on the contended locale's {@link LocaleSummary} and carry on with the other locales.
917
+ * - `GLOSSARY_NOT_FILE_BACKED`: the loaded config's glossary is written inline or absent, so there
918
+ * is no glossary file to read or rewrite. Thrown by {@link readGlossaryFile} and
919
+ * {@link updateGlossaryTerm}, which work on a file-backed glossary alone and never rewrite the
920
+ * config module itself.
921
+ * - `GLOSSARY_UNWRITABLE`: the glossary file could not be written, because it or its directory is
922
+ * read-only, has been removed, or the disk is out of space. Thrown by
923
+ * {@link updateGlossaryTerm}.
663
924
  * - `LOCALE_LAYOUT_INVALID`: the configured `files.pattern` and `files.localeStyle` cannot be
664
- * combined (a segment style needs the `{locale}` token to stand alone between separators), or the
665
- * declared style has no valid path spelling for a configured locale (`zh-Hans` under `posix`, for
666
- * instance), or a locale expands to something that is not a single path segment. Thrown by
667
- * `createLocalePathResolver`, and so by every entry point that resolves a locale to a path, before
668
- * any file is read and before any provider call.
669
- * - `LOCALE_PATH_COLLISION`: two configured locales resolve to the same absolute file path, which
670
- * would make the path-to-locale direction meaningless and would let two concurrent locale workers
671
- * race on one file (thrown by `createLocalePathResolver`, at the same point as the above).
672
- * - `CONCURRENCY_INVALID`: the `concurrency` input is not an integer of at least 1 (thrown by
673
- * `translate` and, per run, by `watch`, before any locale runs).
674
- * - `CONCURRENCY_BUDGET_CONFLICT`: a live run requested `concurrency` greater than 1 while a token
675
- * budget (`maxTokens`) is configured; the two are mutually exclusive because concurrency makes the
676
- * budget's stop guarantee nondeterministic (thrown by `translate` before any provider call; a dry
677
- * run is exempt, since it never folds usage into or consults the budget tracker, so the conflict
678
- * cannot arise).
679
- * - `LOCALE_FAILED` (NOT thrown): the fallback `code` recorded on a failed `LocaleSummary` when a
680
- * per-locale failure carries no string code of its own. See the surfaced-not-thrown distinction
681
- * on `translate`.
682
- */
683
- 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_LAYOUT_INVALID" | "LOCALE_PATH_COLLISION" | "CONCURRENCY_INVALID" | "CONCURRENCY_BUDGET_CONFLICT" | "LOCALE_FAILED";
684
- /** The single structured error the SDK throws or records. Never carries a secret. */
925
+ * combined, or the style has no valid path spelling for a configured locale. Thrown by
926
+ * {@link createLocalePathResolver}, and so by every entry point that maps a locale to a path,
927
+ * before any file is read and before any provider call. {@link doctor} is the exception: it
928
+ * reports the resolver's failure as a failed `source-file` check instead.
929
+ * - `LOCALE_PATH_COLLISION`: two configured locales resolve to the same absolute path, which would
930
+ * make the path-to-locale direction ambiguous and let two locale workers race on one file. Thrown
931
+ * at the same point as `LOCALE_LAYOUT_INVALID`.
932
+ * - `CONCURRENCY_INVALID`: the `concurrency` input is not an integer of at least 1. Thrown by
933
+ * {@link translate} before any locale runs, and by {@link watch} once at startup, before any
934
+ * watching begins, since the value is fixed for the session rather than re-read per run.
935
+ * - `CONCURRENCY_BUDGET_CONFLICT`: a live run requested a `concurrency` above 1 while a token
936
+ * budget is configured. The two are mutually exclusive because concurrency makes the budget's
937
+ * stop guarantee nondeterministic. A dry run is exempt, since it never consults the budget.
938
+ * - `TARGET_UNWRITABLE`: a target locale file could not be written, because its directory is not
939
+ * writable, does not exist, is read-only, or is out of space. The message names the target file
940
+ * relative to `cwd` and the underlying file-system code, never the internal temporary file the
941
+ * atomic write uses. Thrown by {@link editEntry} and {@link retranslateEntry}, which act on one
942
+ * locale. {@link translate} and {@link importWorkbook} do not throw it: they record it on that
943
+ * locale's {@link LocaleSummary} and carry on with the other locales.
944
+ * - `LOCALE_FAILED`: never thrown. It is the fallback code recorded on a failed
945
+ * {@link LocaleSummary} when a per-locale failure carries no code of its own.
946
+ */
947
+ 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" | "GLOSSARY_NOT_FILE_BACKED" | "GLOSSARY_UNWRITABLE" | "LOCALE_LAYOUT_INVALID" | "LOCALE_PATH_COLLISION" | "CONCURRENCY_INVALID" | "CONCURRENCY_BUDGET_CONFLICT" | "TARGET_UNWRITABLE" | "LOCALE_FAILED";
948
+ /**
949
+ * The single structured error the SDK throws. Every whole-run failure surfaces as an `SdkError`
950
+ * carrying a stable {@link SdkErrorCode}; per-locale failures, provider notices, and integrity
951
+ * findings are reported as data on the {@link RunSummary} instead of being thrown.
952
+ *
953
+ * An `SdkError` never carries a secret in its message.
954
+ */
685
955
  declare class SdkError extends Error {
686
- /** The stable {@link SdkErrorCode} for this failure; branch on this, not the message. */
956
+ /** The stable {@link SdkErrorCode} for this failure. Branch on this, not on the message. */
687
957
  readonly code: SdkErrorCode;
688
958
  /**
689
959
  * @param code - The stable failure code.
690
- * @param message - A fixed, secret-free message; the SDK never holds a key to put here.
960
+ * @param message - A human-readable description of the failure. Never contains a secret.
691
961
  */
692
962
  constructor(code: SdkErrorCode, message: string);
693
963
  }
694
964
 
695
- /** Zod schema accepting exactly one of {@link SUPPORTED_FORMATS}. */
965
+ /**
966
+ * Zod schema accepting exactly one of the eight source formats verbatra can read and write. Each
967
+ * member names the ecosystem it round-trips, not just a file extension, because several of them share
968
+ * one: `i18next-json` is i18next's nested JSON with its plural key suffixes, `vue-i18n-json` is Vue
969
+ * I18n's JSON with its pipe-separated plural values, `next-intl-json` is next-intl's ICU-message JSON,
970
+ * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining four are `xliff` (the XLIFF
971
+ * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle), and
972
+ * `properties` (Java and Spring `.properties` files).
973
+ *
974
+ * The set is closed: a format outside it cannot be represented, so adding one means adding a member
975
+ * here and an adapter that claims it.
976
+ */
696
977
  declare const supportedFormatSchema: z.ZodEnum<{
697
978
  "i18next-json": "i18next-json";
698
979
  "vue-i18n-json": "vue-i18n-json";
@@ -703,12 +984,23 @@ declare const supportedFormatSchema: z.ZodEnum<{
703
984
  arb: "arb";
704
985
  properties: "properties";
705
986
  }>;
706
- /** One of the supported source formats; a member of {@link SUPPORTED_FORMATS}. */
987
+ /** One of the eight supported source formats; the inferred type of {@link supportedFormatSchema}. */
707
988
  type SupportedFormat = z.infer<typeof supportedFormatSchema>;
708
989
 
709
990
  /**
710
- * A single, format-neutral translation unit. Placeholders are supplied already
711
- * extracted; core never derives them from the value.
991
+ * A single, format-neutral translation unit. Placeholders are supplied already extracted; core never
992
+ * derives them from the value, so an adapter that reads a file is the one authority on what counts as
993
+ * a placeholder in that format.
994
+ *
995
+ * The fields are: `key`, the entry's identifier within its namespace, non-empty and already flattened
996
+ * to the dotted path an adapter produced; `namespace`, the namespace the entry belongs to, empty when
997
+ * the format has no namespacing; `value`, the translatable string itself, which is untrusted input and
998
+ * may be empty; `description` and `meaning`, both optional free-form context that a provider is told to
999
+ * use only for disambiguation and never to translate or echo back; `placeholders`, the format's
1000
+ * placeholder tokens in document order, used for the output integrity check and to decide which entries
1001
+ * a placeholder-blind provider must withhold; and `isPlural`, whether the entry carries plural forms
1002
+ * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
1003
+ * decides and which feeds the content hash.
712
1004
  */
713
1005
  declare const translationEntrySchema: z.ZodObject<{
714
1006
  key: z.ZodString;
@@ -719,23 +1011,32 @@ declare const translationEntrySchema: z.ZodObject<{
719
1011
  placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
720
1012
  isPlural: z.ZodBoolean;
721
1013
  }, z.core.$strip>;
722
- /** The validated shape of one translation unit; the inferred type of {@link translationEntrySchema}. */
1014
+ /**
1015
+ * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
1016
+ * made deeply readonly. See that schema for what each field carries.
1017
+ */
723
1018
  type TranslationEntry = Readonly<z.infer<typeof translationEntrySchema>>;
724
- /** All translation entries for one locale and namespace, keyed by entry key. */
1019
+
1020
+ /**
1021
+ * All translation entries for one locale and namespace, keyed by entry key. This is the neutral
1022
+ * intermediate representation every adapter reads into and writes back out of, so nothing downstream
1023
+ * of an adapter parses a format's own syntax.
1024
+ */
725
1025
  interface LocaleResource {
726
1026
  /** The locale these entries belong to (for example, "en" or "de"). */
727
1027
  readonly locale: string;
728
- /** The namespace these entries belong to. */
1028
+ /** The namespace these entries belong to; empty when the format has no namespacing. */
729
1029
  readonly namespace: string;
730
- /** The source format the resource came from, for round-trip fidelity. */
1030
+ /** The source format the resource came from, retained for round-trip fidelity on write. */
731
1031
  readonly format: SupportedFormat;
732
- /** Entries addressable by key. */
1032
+ /** Entries addressable by key, in the order the source file defined them. */
733
1033
  readonly entries: ReadonlyMap<string, TranslationEntry>;
734
1034
  }
735
1035
 
736
1036
  /**
737
1037
  * Result of comparing source placeholders against translated-output placeholders as MULTISETS
738
- * (counts matter). Always returned (never thrown) for an ordinary mismatch.
1038
+ * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
1039
+ * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
739
1040
  */
740
1041
  interface PlaceholderIntegrityResult {
741
1042
  /** True when nothing is missing or extra: the multisets match, regardless of order. */
@@ -744,17 +1045,21 @@ interface PlaceholderIntegrityResult {
744
1045
  readonly missing: readonly string[];
745
1046
  /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
746
1047
  readonly extra: readonly string[];
747
- /** Same multiset present in both, but in a different order. */
1048
+ /** Same multiset present in both, but in a different order. Never true alongside a mismatch. */
748
1049
  readonly reordered: boolean;
749
1050
  }
750
1051
 
751
1052
  /**
752
- * The result of reading a file into core's intermediate representation.
1053
+ * The result of reading a file into core's intermediate representation. The two diagnostic lists are
1054
+ * reported as data rather than thrown, so one bad leaf never fails a whole read.
753
1055
  */
754
1056
  interface ReadResult {
755
1057
  /** The parsed locale resource. */
756
1058
  readonly resource: LocaleResource;
757
- /** Keys whose values are invalid for the format's message syntax. Empty for non-ICU formats. */
1059
+ /**
1060
+ * Keys whose values are invalid for the format's message syntax. Empty for a format with no
1061
+ * message-syntax check, which treats every value as valid.
1062
+ */
758
1063
  readonly invalidIcuKeys: readonly string[];
759
1064
  /**
760
1065
  * Dotted paths of leaves that were present in the source but excluded because they are not
@@ -768,22 +1073,14 @@ interface ReadResult {
768
1073
  * The contract every format adapter implements. A new format attaches by implementing this interface
769
1074
  * and registering it in an {@link AdapterRegistry}.
770
1075
  *
771
- * For a JSON-family format, implement via {@link createJsonFileAdapter} rather than from scratch. A
772
- * non-JSON format implements this interface directly and first needs its format added to core's
773
- * `SupportedFormat`.
774
- *
775
- * @example
776
- * ```ts
777
- * const registry = createDefaultRegistry();
778
- * const resolution = registry.resolve("locales/en.json", { format: "i18next-json" });
779
- * if (resolution.status === "resolved") {
780
- * const { resource } = await resolution.adapter.read("locales/en.json", "en");
781
- * await resolution.adapter.write(resource, "locales/en.json");
782
- * }
783
- * ```
1076
+ * Implement it through one of `@verbatra/format-adapters`' shared factories rather than from
1077
+ * scratch: `createTreeFileAdapter` for a nested-tree format (with `createJsonFileAdapter` as its
1078
+ * JSON specialization) and `createFlatFileAdapter` for a flat key/value format. A format that fits
1079
+ * neither shape implements this interface directly, and either way it first needs its member added
1080
+ * to core's {@link SupportedFormat}.
784
1081
  */
785
1082
  interface FormatAdapter {
786
- /** The single format this adapter handles (a `SupportedFormat` from core). */
1083
+ /** The single format this adapter handles (a {@link SupportedFormat} from core). */
787
1084
  readonly format: SupportedFormat;
788
1085
  /**
789
1086
  * Detect whether this adapter can handle a file, by path extension and an optional content sample.
@@ -801,7 +1098,7 @@ interface FormatAdapter {
801
1098
  * @param filePath - The file to read.
802
1099
  * @param locale - The locale to tag the resource with.
803
1100
  * @returns The parsed resource and the keys whose values are invalid for the format.
804
- * @throws {@link AdapterError} when the content is malformed, oversized, or structurally invalid
1101
+ * @throws `AdapterError` when the content is malformed, oversized, or structurally invalid
805
1102
  * (the implementation names the specific codes). A missing or unopenable path instead rejects with
806
1103
  * the underlying filesystem error.
807
1104
  */
@@ -811,7 +1108,7 @@ interface FormatAdapter {
811
1108
  *
812
1109
  * @param resource - The resource to serialize.
813
1110
  * @param filePath - The destination file.
814
- * @throws {@link AdapterError} if the resource cannot be represented in the format; rejects with the
1111
+ * @throws `AdapterError` if the resource cannot be represented in the format; rejects with the
815
1112
  * underlying filesystem error on a write failure.
816
1113
  */
817
1114
  write(resource: LocaleResource, filePath: string): Promise<void>;
@@ -823,8 +1120,9 @@ interface FormatAdapter {
823
1120
  */
824
1121
  extractPlaceholders(value: string): readonly string[];
825
1122
  /**
826
- * Validate a single value against the format's message syntax before it is written. Non-ICU formats
827
- * (i18next, vue-i18n, ngx-translate) treat every value as valid and return true.
1123
+ * Validate a single value against the format's message syntax before it is written. Only the
1124
+ * ICU-message formats carry a real check today (next-intl and ARB); every other adapter has no
1125
+ * message syntax to violate and returns true for any value.
828
1126
  *
829
1127
  * @param value - The candidate translated value to validate.
830
1128
  * @returns True when the value is valid for the format's message syntax. Does not throw; an
@@ -832,47 +1130,78 @@ interface FormatAdapter {
832
1130
  */
833
1131
  validateMessage(value: string): boolean;
834
1132
  /**
835
- * Optional branch-aware placeholder comparison for formats with plural/select sub-message structure.
836
- * When present, callers use it instead of independently extracting each side's placeholders with
837
- * {@link extractPlaceholders} and diffing the flat lists, since flattening a plural/select value loses
838
- * which branch a placeholder came from. Absent for every non-ICU adapter, which is compared via
839
- * `extractPlaceholders` plus `checkPlaceholders` as before.
1133
+ * Optional whole-value placeholder comparison, used by callers instead of independently extracting
1134
+ * each side's placeholders with {@link extractPlaceholders} and diffing the flat lists. Two adapter
1135
+ * families define one, for different reasons:
1136
+ *
1137
+ * - The ICU formats (next-intl and ARB) compare branch by branch, because flattening a
1138
+ * plural/select value loses which branch a placeholder came from.
1139
+ * - The double-brace formats (i18next, ngx-translate, and YAML) add a one-directional
1140
+ * single-brace check on top of their flat multiset comparison: a `{name}`-shaped token present
1141
+ * in the translated value but absent from the source is reported as `extra`. Single-brace text
1142
+ * is ordinary literal content under these formats' default delimiters, so it is never required
1143
+ * to survive a translation, but inventing one is never a legitimate translation either.
1144
+ *
1145
+ * Absent for the remaining adapters, which are compared via `extractPlaceholders` plus a flat
1146
+ * multiset check.
840
1147
  *
841
1148
  * @param sourceValue - The source value.
842
1149
  * @param targetValue - The translated value to check against it.
843
- * @returns The merged placeholder-integrity result across every branch. Does not throw.
1150
+ * @returns The merged placeholder-integrity result. Does not throw.
844
1151
  */
845
1152
  comparePlaceholders?(sourceValue: string, targetValue: string): PlaceholderIntegrityResult;
846
1153
  }
847
1154
 
848
- /** Outcome of resolving an adapter for a file. Structured; never throws. */
1155
+ /**
1156
+ * Outcome of resolving an adapter for a file. Structured, never thrown: an unresolvable file is a
1157
+ * status the caller branches on, not an exception.
1158
+ *
1159
+ * `resolved` carries the single {@link FormatAdapter} to use. `no-match` means nothing claimed the
1160
+ * file, and reports the formats that were tried (every registered format for a detection attempt, or
1161
+ * just the one requested when an explicit format was given). `ambiguous` means more than one adapter
1162
+ * claimed the file, which is routine since every JSON adapter claims `.json`; it reports the competing
1163
+ * candidates instead of guessing, and the caller resolves it by naming a format explicitly.
1164
+ */
849
1165
  type AdapterResolution = {
1166
+ /** Exactly one adapter claimed the file. */
850
1167
  readonly status: "resolved";
1168
+ /** The adapter to read and write the file with. */
851
1169
  readonly adapter: FormatAdapter;
852
1170
  } | {
1171
+ /** No registered adapter claimed the file. */
853
1172
  readonly status: "no-match";
1173
+ /** The file that could not be matched. */
854
1174
  readonly filePath: string;
1175
+ /**
1176
+ * The formats that were tried: every registered format for a detection attempt, or just the
1177
+ * one requested when an explicit format was given.
1178
+ */
855
1179
  readonly triedFormats: readonly SupportedFormat[];
856
1180
  } | {
1181
+ /** More than one adapter claimed the file, so detection cannot choose between them. */
857
1182
  readonly status: "ambiguous";
1183
+ /** The file that several adapters claimed. */
858
1184
  readonly filePath: string;
1185
+ /** The competing formats. Resolve the ambiguity by naming one of them explicitly. */
859
1186
  readonly candidates: readonly SupportedFormat[];
860
1187
  };
861
1188
  /** Options for {@link AdapterRegistry.resolve}: select a format explicitly, or aid detection. */
862
1189
  interface ResolveOptions {
863
- /** A content sample to aid detection. */
1190
+ /** A leading content sample to aid detection. Ignored when `format` is given. */
864
1191
  readonly sample?: string;
865
1192
  /** Bypass detection and select this format explicitly. */
866
1193
  readonly format?: SupportedFormat;
867
1194
  }
868
1195
  /**
869
- * Holds the registered adapters and resolves one for a file. Open for extension:
870
- * adapters attach through register without changing resolution logic.
1196
+ * Holds the registered adapters and resolves one for a file. Open for extension: adapters attach
1197
+ * through {@link AdapterRegistry.register} without changing resolution logic, and resolution never
1198
+ * throws.
871
1199
  */
872
1200
  declare class AdapterRegistry {
873
1201
  private readonly adapters;
874
1202
  /**
875
- * Register an adapter.
1203
+ * Register an adapter. Registration order is the order detection consults them, and a format may be
1204
+ * registered more than once, in which case an explicit-format lookup takes the first.
876
1205
  *
877
1206
  * @param adapter - The adapter to add.
878
1207
  * @returns This registry, for chaining.
@@ -880,11 +1209,6 @@ declare class AdapterRegistry {
880
1209
  register(adapter: FormatAdapter): this;
881
1210
  private formats;
882
1211
  private resolveByFormat;
883
- /**
884
- * Resolve by asking every registered adapter's `canHandle`. Several adapters can claim one file
885
- * (all JSON adapters claim `.json`), so more than one match is reported as `ambiguous` rather
886
- * than guessed at.
887
- */
888
1212
  private resolveByDetection;
889
1213
  /**
890
1214
  * Resolve the adapter for a file, by explicit format when given, otherwise by detection.
@@ -897,1447 +1221,1763 @@ declare class AdapterRegistry {
897
1221
  resolve(filePath: string, options?: ResolveOptions): AdapterResolution;
898
1222
  }
899
1223
 
900
- /** Per-locale drift status: counts only, no key lists. */
1224
+ /** One locale's counts in a {@link CheckSummary}. */
901
1225
  interface LocaleCheckSummary {
902
- /** The target locale this entry reports on. */
1226
+ /** The target locale these counts describe. */
903
1227
  readonly locale: string;
904
- /** Keys present in source but absent from the target. */
1228
+ /** Number of source keys with no translation in this locale yet. */
905
1229
  readonly missing: number;
906
- /** Keys whose source changed since the target was last translated. */
1230
+ /** Number of keys whose source text changed since the locale was last translated. */
907
1231
  readonly stale: number;
908
- /** Keys whose recorded baseline still matches the source. */
1232
+ /** Number of keys whose translation still matches the source recorded in the lock-file. */
909
1233
  readonly upToDate: number;
910
- /** True when nothing needs (re)translating for this locale. */
1234
+ /** True when this locale has nothing missing and nothing stale. */
911
1235
  readonly inSync: boolean;
912
1236
  }
913
- /** The aggregate read-only status across all checked target locales. */
1237
+ /** The result of {@link check}: per-locale counts plus one project-wide verdict. */
914
1238
  interface CheckSummary {
915
- /** True only when every checked locale is in sync. */
1239
+ /** True only when every reported locale is in sync. This is the value a CI gate should assert on. */
916
1240
  readonly inSync: boolean;
917
- /** One entry per checked target locale, in config order. */
1241
+ /** Per-locale counts, in configured target order. */
918
1242
  readonly locales: readonly LocaleCheckSummary[];
919
1243
  }
920
- /** Input for {@link check}: the validated config and which locales to check. */
1244
+ /** Input for {@link check}. */
921
1245
  interface CheckInput {
922
- /** The validated configuration (typically from {@link loadConfig}). */
1246
+ /** The resolved project config, normally from {@link loadConfig}. */
923
1247
  readonly config: VerbatraConfig;
924
- /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
1248
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
925
1249
  readonly cwd?: string;
926
- /** Subset of target locales to check; defaults to all configured target locales. */
1250
+ /** Restrict the report to these target locales. Defaults to every configured target locale. */
927
1251
  readonly locales?: readonly string[];
928
1252
  }
929
- /** Composition seam for {@link check}: inject a registry and a file system for tests. */
1253
+ /** Injectable dependencies for {@link check}. Every field has a working default. */
930
1254
  interface CheckDeps {
1255
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
931
1256
  readonly adapterRegistry?: AdapterRegistry;
1257
+ /** File-system port. Defaults to the real file system. */
932
1258
  readonly fs?: SdkFs;
933
1259
  }
934
1260
  /**
935
- * Reports how many keys are missing or stale per target locale, without calling a provider, writing
936
- * any file, or touching the lock. Each locale is reported as counts only (missing, stale,
937
- * up-to-date); a locale is `inSync` when nothing is missing or stale, and the top-level `inSync` is
938
- * true only when every checked locale is. Orphaned keys and integrity are not reported, since they
939
- * concern a write; use {@link diff} for the key lists.
1261
+ * Reports whether every target locale is up to date, without writing anything and without calling
1262
+ * the provider. This is the CI gate: run it on a pull request and fail the build when
1263
+ * {@link CheckSummary.inSync} is false.
1264
+ *
1265
+ * Staleness is judged against the lock-file baseline, not against the target file's mere existence,
1266
+ * so a key whose source text changed after it was translated counts as stale even though a
1267
+ * translation is present. A locale file that does not exist yet is treated as empty rather than as
1268
+ * an error, so a newly added locale reports every key as missing.
1269
+ *
1270
+ * Use {@link diff} instead when you need the key names rather than the counts.
940
1271
  *
941
- * @param input - The validated config and which locales to check.
942
- * @param deps - Optional composition seams (registry, file system) for tests.
943
- * @returns The aggregate and per-locale drift status.
944
- * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
945
- * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
946
- * among the configured target locales.
1272
+ * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1273
+ * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1274
+ * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
1275
+ * error from a target file.
1276
+ *
1277
+ * @param input - The config and the optional locale filter.
1278
+ * @param deps - Optional adapter registry and file-system overrides.
1279
+ * @returns Per-locale counts and the project-wide in-sync verdict.
1280
+ *
1281
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1282
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1283
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
1284
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1285
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1286
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1287
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
1288
+ * unsupported version.
1289
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
947
1290
  */
948
1291
  declare function check(input: CheckInput, deps?: CheckDeps): Promise<CheckSummary>;
949
1292
 
950
- /** Per-locale pending change: the key lists, not counts. */
1293
+ /** One locale's pending work in a {@link DiffSummary}, as key names rather than counts. */
951
1294
  interface LocaleDiff {
952
- /** The target locale this entry reports on. */
1295
+ /** The target locale this entry describes. */
953
1296
  readonly locale: string;
954
- /** Keys present in source but absent from the target; would be added by a run. */
1297
+ /** Keys present in the source but absent from this locale. */
955
1298
  readonly missing: readonly string[];
956
- /** Keys whose source changed since last translated; would be re-translated. */
1299
+ /** Keys whose source text changed since this locale was last translated. */
957
1300
  readonly changed: readonly string[];
958
- /** Keys present in target but absent from source; report-only, never pending. */
1301
+ /**
1302
+ * Keys present in this locale but no longer in the source. They are reported, never removed,
1303
+ * unless a run is asked to prune.
1304
+ */
959
1305
  readonly orphaned: readonly string[];
960
- /** True when the locale has missing or changed keys; orphaned keys do not count. */
1306
+ /**
1307
+ * True when this locale has missing or changed keys. Orphaned keys alone do not count as pending,
1308
+ * because they need no translation work.
1309
+ */
961
1310
  readonly hasPendingChanges: boolean;
962
1311
  }
963
- /** The aggregate read-only diff across all checked target locales. */
1312
+ /** The result of {@link diff}: per-locale key lists plus one project-wide verdict. */
964
1313
  interface DiffSummary {
965
- /** True when any checked locale has pending changes. */
1314
+ /** True when any locale has missing or changed keys. */
966
1315
  readonly hasPendingChanges: boolean;
967
- /** One entry per checked target locale, in config order. */
1316
+ /** Per-locale key lists, in configured target order. */
968
1317
  readonly locales: readonly LocaleDiff[];
969
1318
  }
970
- /** Input for {@link diff}: the validated config and which locales to diff. */
1319
+ /** Input for {@link diff}. */
971
1320
  interface DiffInput {
972
- /** The validated configuration (typically from {@link loadConfig}). */
1321
+ /** The resolved project config, normally from {@link loadConfig}. */
973
1322
  readonly config: VerbatraConfig;
974
- /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
1323
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
975
1324
  readonly cwd?: string;
976
- /** Subset of target locales to diff; defaults to all configured target locales. */
1325
+ /** Restrict the report to these target locales. Defaults to every configured target locale. */
977
1326
  readonly locales?: readonly string[];
978
1327
  }
979
- /** Composition seam for {@link diff}: inject a registry and a file system for tests. */
1328
+ /** Injectable dependencies for {@link diff}. Every field has a working default. */
980
1329
  interface DiffDeps {
1330
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
981
1331
  readonly adapterRegistry?: AdapterRegistry;
1332
+ /** File-system port. Defaults to the real file system. */
982
1333
  readonly fs?: SdkFs;
983
1334
  }
984
1335
  /**
985
- * Reports the exact pending change per target locale as three key lists (missing, changed,
986
- * orphaned), without calling a provider, writing any file, or touching the lock. The detailed
987
- * sibling of {@link check}. A locale's `hasPendingChanges` is driven by missing or changed only;
988
- * orphaned keys are reported but do not flip it, since a default `translate` run does not prune.
989
- * The top-level `hasPendingChanges` is true when any checked locale has pending changes.
1336
+ * Reports the per-locale drift between the source and each target locale as key names, without
1337
+ * writing anything and without calling the provider. It answers "what exactly would a run change",
1338
+ * where {@link check} answers "is anything pending at all".
990
1339
  *
991
- * @param input - The validated config and which locales to diff.
992
- * @param deps - Optional composition seams (registry, file system) for tests.
993
- * @returns The aggregate and per-locale pending change.
994
- * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
995
- * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
996
- * among the configured target locales.
1340
+ * The comparison runs against the lock-file baseline, so `changed` means the source text moved
1341
+ * since the key was last translated rather than merely that the two strings differ. Orphaned keys
1342
+ * are reported but never removed here; pruning happens only in {@link translate}.
1343
+ *
1344
+ * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1345
+ * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1346
+ * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
1347
+ * error from a target file.
1348
+ *
1349
+ * @param input - The config and the optional locale filter.
1350
+ * @param deps - Optional adapter registry and file-system overrides.
1351
+ * @returns Per-locale missing, changed, and orphaned key lists.
1352
+ *
1353
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1354
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1355
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
1356
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1357
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1358
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1359
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
1360
+ * unsupported version.
1361
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
997
1362
  */
998
1363
  declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
999
1364
 
1000
- /** Why {@link gateCandidateValue} rejected a candidate value. */
1365
+ /**
1366
+ * Which project-setup question a {@link DoctorCheck} answers.
1367
+ *
1368
+ * - `config`: a config file was found and passes validation.
1369
+ * - `format-adapter`: the configured `format` resolves to a file adapter.
1370
+ * - `provider`: the configured `provider.id` resolves to a provider factory.
1371
+ * - `api-key`: the environment variable the configured provider reads its key from is set.
1372
+ * - `source-file`: the source locale file exists at its resolved path, is a regular file, and
1373
+ * parses under the configured format.
1374
+ */
1375
+ type DoctorCheckId = "config" | "format-adapter" | "provider" | "api-key" | "source-file";
1376
+ /**
1377
+ * The verdict on one {@link DoctorCheck}. `skipped` is reported only for the checks that need a
1378
+ * loaded config when the `config` check itself failed, so a skipped check is never a problem of its
1379
+ * own.
1380
+ */
1381
+ type DoctorCheckStatus = "pass" | "fail" | "skipped";
1382
+ /** One project-setup question and its verdict. */
1383
+ interface DoctorCheck {
1384
+ /** Which question this check answers. */
1385
+ readonly id: DoctorCheckId;
1386
+ /** A short human-readable name for the check, stable across runs. */
1387
+ readonly title: string;
1388
+ /** The verdict. Only `fail` makes {@link DoctorResult.ok} false. */
1389
+ readonly status: DoctorCheckStatus;
1390
+ /**
1391
+ * Why the check reached its verdict. On a failure this carries the underlying error message
1392
+ * verbatim, so a config validation problem reads exactly as {@link loadConfig} words it. It names
1393
+ * environment variables and paths, never an API key value.
1394
+ */
1395
+ readonly detail: string;
1396
+ }
1397
+ /** The result of {@link doctor}: every check that ran, and one project-wide verdict. */
1398
+ interface DoctorResult {
1399
+ /** True only when no check failed. This is the value a script should branch on. */
1400
+ readonly ok: boolean;
1401
+ /** Every check, always in the same order, one entry per {@link DoctorCheckId}. */
1402
+ readonly checks: readonly DoctorCheck[];
1403
+ }
1404
+ /** Input for {@link doctor}. */
1405
+ interface DoctorInput {
1406
+ /** Directory to search the config from, and the base for locale paths. Defaults to the process working directory. */
1407
+ readonly cwd?: string;
1408
+ /** An explicit config file to validate, bypassing the search. A missing file is an error rather than a failed check. */
1409
+ readonly configPath?: string;
1410
+ }
1411
+ /** Injectable dependencies for {@link doctor}. Every field has a working default. */
1412
+ interface DoctorDeps {
1413
+ /** Format-adapter registry to resolve the configured format against. Defaults to the built-in registry. */
1414
+ readonly adapterRegistry?: AdapterRegistry;
1415
+ /**
1416
+ * File-system port. Threaded into the config loader, so it backs the glossary-file read the
1417
+ * `config` check performs, and used to read and parse the source locale file. Defaults to the
1418
+ * real file system.
1419
+ */
1420
+ readonly fs?: SdkFs;
1421
+ /** Config loader. Defaults to {@link loadConfigWithMeta}. */
1422
+ readonly loadConfig?: (options: LoadConfigOptions) => Promise<LoadedConfig>;
1423
+ }
1424
+ /**
1425
+ * Validates a project's setup and spends nothing: no provider is constructed, no network request is
1426
+ * made, and no file is written. Run it before {@link translate} on a fresh project, or when a run
1427
+ * failed and you want the whole list of problems rather than the first one.
1428
+ *
1429
+ * Five checks run: the config loads and validates, the configured format resolves to an adapter,
1430
+ * the configured provider ID resolves to a factory, the environment variable that provider reads
1431
+ * its API key from is set, and the source locale file can be read. Every check runs even when an
1432
+ * earlier one failed, so one call reports every independent problem. The API key is checked by
1433
+ * variable name only: its value is never read, never returned, and never validated against a
1434
+ * provider.
1435
+ *
1436
+ * The source-file check reads and parses the file rather than only probing for its existence, so a
1437
+ * directory standing in for it, an empty file, and malformed content are all reported here rather
1438
+ * than surfacing later as a whole-run failure of {@link check} or {@link translate}. The failure
1439
+ * detail is the same message those entry points raise. When the configured format resolves to no
1440
+ * adapter there is nothing to parse with, so the check falls back to existence alone and says so.
1441
+ *
1442
+ * The `openai-compatible` provider is the one exception on the key check. It falls back to a
1443
+ * placeholder key, so a missing variable passes unless the config names its own variable through
1444
+ * `provider.options.apiKeyEnvVar`, which then has to be set.
1445
+ *
1446
+ * A target locale file is not checked at all: a missing one is not a problem, because
1447
+ * {@link translate} creates it. The source locale file is checked, because every other entry point
1448
+ * fails on it.
1449
+ *
1450
+ * When the config cannot be loaded the four config-dependent checks report `skipped` rather than a
1451
+ * verdict they could not reach, and {@link DoctorResult.ok} is false because the config check
1452
+ * itself failed.
1453
+ *
1454
+ * @param input - The working directory and an optional explicit config path.
1455
+ * @param deps - Optional adapter registry, file-system, and config-loader overrides.
1456
+ * @returns Every check with its verdict, and the project-wide `ok` verdict.
1457
+ *
1458
+ * @throws {@link SdkError} `CONFIG_NOT_FOUND`: an explicit `configPath` was given and no file
1459
+ * exists there. A config that is merely absent from the search is a failed check instead.
1460
+ *
1461
+ * @example
1462
+ * ```ts
1463
+ * import { doctor } from "@verbatra/sdk";
1464
+ *
1465
+ * const report = await doctor();
1466
+ * for (const check of report.checks) {
1467
+ * console.log(`${check.status}: ${check.title} - ${check.detail}`);
1468
+ * }
1469
+ * process.exitCode = report.ok ? 0 : 1;
1470
+ * ```
1471
+ */
1472
+ declare function doctor(input?: DoctorInput, deps?: DoctorDeps): Promise<DoctorResult>;
1473
+
1474
+ /**
1475
+ * Why a candidate translation was refused before it could be written. The same gate guards
1476
+ * {@link editEntry} and {@link retranslateEntry}, so a hand-typed value and a provider-produced one
1477
+ * are held to identical standards.
1478
+ *
1479
+ * A rejected value is never written to the locale file and never recorded in the lock-file, so the
1480
+ * previous translation stays intact.
1481
+ *
1482
+ * - `placeholder`: the candidate does not carry the same placeholders as the source, so
1483
+ * interpolation would break at runtime. For the double-brace formats (i18next, ngx-translate, and
1484
+ * YAML) this also covers a single-brace `{name}`-shaped token the candidate invented and the
1485
+ * source never had, which is a fabrication whichever interpolation delimiters the project uses.
1486
+ * - `icu`: the candidate is not a valid ICU message under the configured format's adapter.
1487
+ * - `degenerate`: the candidate collapsed into runaway output rather than a translation. Two shapes
1488
+ * are detected: the candidate is at least twelve times the length of a source of meaningful
1489
+ * length, or a short unit repeats consecutively enough to dominate the value. An untranslated
1490
+ * echo of the source is not degenerate by this rule; it surfaces as the `EQUALS_SOURCE` review
1491
+ * reason instead, which flags rather than refuses.
1492
+ * - `empty`: the source has text but the candidate is blank, which would silently erase a string.
1493
+ */
1001
1494
  type IntegrityGateReason = "placeholder" | "icu" | "degenerate" | "empty";
1002
1495
 
1003
- /** Input for {@link editEntry}: the validated config, exactly one target locale/key pair, and the human-typed replacement value. */
1496
+ /** Input for {@link editEntry}. */
1004
1497
  interface EditEntryInput {
1005
- /** The validated configuration (typically from {@link loadConfig}). */
1498
+ /** The resolved project config, normally from {@link loadConfig}. */
1006
1499
  readonly config: VerbatraConfig;
1007
- /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
1500
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
1008
1501
  readonly cwd?: string;
1009
- /** The target locale to write the correction into. Must be a configured target locale. */
1502
+ /** The target locale to write to. Must be a configured target locale. */
1010
1503
  readonly locale: string;
1011
- /** The source key being corrected. Must exist in the source resource. */
1504
+ /** The key to edit. Must exist in the source resource. */
1012
1505
  readonly key: string;
1013
- /** The human-typed replacement value. */
1506
+ /** The new translation. It is accepted only if it passes the integrity gate. */
1014
1507
  readonly value: string;
1015
1508
  }
1016
- /** Composition seam for {@link editEntry}: inject a registry and a file system for tests. No provider builder: this seam never calls a provider. */
1509
+ /** Injectable dependencies for {@link editEntry}. Every field has a working default. */
1017
1510
  interface EditEntryDeps {
1511
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1018
1512
  readonly adapterRegistry?: AdapterRegistry;
1513
+ /** File-system port. Defaults to the real file system. */
1019
1514
  readonly fs?: SdkFs;
1020
1515
  }
1021
1516
  /**
1022
- * The two-armed result of one edit attempt: acceptance carries the newly written value; a
1023
- * rejection carries the candidate value and which {@link gateCandidateValue} check failed it,
1024
- * with nothing written. Deliberately has no review-reasons field: a human-typed correction never
1025
- * touches a provider, so there is no provider-derived review signal to report.
1517
+ * The outcome of {@link editEntry}. A rejection is data rather than an exception, because a value
1518
+ * that fails the integrity gate is an expected editing outcome that a UI needs to explain, not a
1519
+ * failure of the call.
1026
1520
  */
1027
1521
  type EditEntryResult = {
1522
+ /** The value passed the integrity gate and was written. */
1028
1523
  readonly accepted: true;
1524
+ /** The value now stored for the key. */
1029
1525
  readonly value: string;
1030
1526
  } | {
1527
+ /** The value was refused; nothing was written and the previous translation is intact. */
1031
1528
  readonly accepted: false;
1529
+ /** Which integrity rule the value broke. */
1032
1530
  readonly reason: IntegrityGateReason;
1531
+ /** The rejected value, echoed back so a UI can show what was refused. */
1033
1532
  readonly value: string;
1034
1533
  };
1035
1534
  /**
1036
- * Writes exactly one human-typed correction for exactly one target locale, gated through the
1037
- * shared {@link gateCandidateValue} before anything reaches disk. On acceptance, writes the target
1038
- * locale file (merging just this one key into its current entries, every other key untouched) and
1039
- * updates the lock entry for this key only (see {@link updateLockFileLocale}'s `"merge"` mode); on
1040
- * rejection, writes nothing. Never calls a provider: {@link EditEntryDeps} has no provider seam,
1041
- * so there is no way to construct or call one. On acceptance it also feeds the accepted value into
1042
- * the local translation-memory cache (best-effort, keyed by the source content hash and the config
1043
- * fingerprint), so a later run can reuse it for free; a cache failure is swallowed and never fails
1044
- * the edit.
1045
- *
1046
- * `locale` and `key` are resolved fresh on every call, never from a cache: `locale` against
1047
- * `config.targetLocales`, `key` against the source resource read fresh from disk.
1048
- *
1049
- * The target-file write and the lock-file update run inside one held `withLocaleWriteLock`
1050
- * critical section, so no second writer for this locale can observe the target file updated but
1051
- * the lock entry not yet, or vice versa. Reading the source, resolving the key, and selecting the
1052
- * adapter stay outside the lock: they are read-only and need no protection.
1053
- *
1054
- * Never touches `.verbatra-local/run-status.json`: that file is rewritten wholesale by
1055
- * `translate()`/`watch()` outside any locale lock, so patching it here would race that rewrite. A
1056
- * key edited here stays stale in that persisted snapshot until the next real run, which self-heals
1057
- * it since this call already advanced the lock hash.
1058
- *
1059
- * @param input - The validated config, the target locale, the source key, and the replacement value.
1060
- * @param deps - Optional composition seams (registry, file system) for tests.
1061
- * @returns The two-armed {@link EditEntryResult}.
1535
+ * Saves a manually edited translation for a single key. This is the write path behind a review UI:
1536
+ * a human corrects one string and it is persisted without re-running the whole project.
1537
+ *
1538
+ * The edit is held to the same integrity gate as a provider translation, so a value that drops a
1539
+ * placeholder, breaks ICU syntax, runs away into a hugely oversized or repetition-dominated value,
1540
+ * or blanks a non-empty string is refused. A refusal comes back as `accepted: false` rather than as
1541
+ * a thrown error, and nothing is written.
1542
+ *
1543
+ * The locale's write lock is taken for the whole read-modify-write, so an edit cannot interleave
1544
+ * with a concurrent {@link translate} run on the same locale. The gate runs inside that lock, so a
1545
+ * refused edit takes and releases the lock as well and can therefore fail with `LOCK_CONTENDED`
1546
+ * even though it would have written nothing. An accepted edit then updates the lock-file baseline
1547
+ * and feeds the translation memory, which means a later run treats the key as up to date and
1548
+ * reuses the edited text rather than paying the provider to translate it again.
1549
+ *
1550
+ * Note that the target locale file surfaces the adapter's own error and code rather than a wrapped
1551
+ * {@link SdkError}, on the write as well as on the read, because only the source read is wrapped.
1552
+ * A malformed target file fails the read with a message naming the offending locale and the
1553
+ * resolved path. The write raises the adapter's error too, when the entries cannot be represented
1554
+ * in the configured format or the existing destination file cannot be read back to be updated in
1555
+ * place, and that error is re-thrown unchanged so its own code survives for a caller that maps
1556
+ * adapter codes to its own copy. A caller that maps SDK codes should be ready for an unrecognized
1557
+ * error from a target file on either path.
1558
+ *
1559
+ * @param input - The config, locale, key, and new value.
1560
+ * @param deps - Optional adapter registry and file-system overrides.
1561
+ * @returns Whether the value was accepted, and on rejection the reason.
1562
+ *
1062
1563
  * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1063
- * @throws {@link SdkError} `UNKNOWN_LOCALE`: `locale` is not among `config.targetLocales`.
1064
- * @throws {@link SdkError} `SOURCE_UNREADABLE` / `SOURCE_INVALID`: the source locale file is absent
1065
- * or unreadable.
1066
- * @throws {@link SdkError} `UNKNOWN_KEY`: `key` does not exist in the source resource.
1564
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: the requested locale is not a configured target locale.
1565
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1566
+ * cannot be combined, or the locale has no valid path spelling under that style.
1567
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1568
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1569
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1570
+ * @throws {@link SdkError} `UNKNOWN_KEY`: the key is not present in the source resource.
1571
+ * @throws {@link SdkError} `LOCK_CONTENDED`: the locale's write lock could not be acquired before
1572
+ * the timeout elapsed.
1573
+ * @throws {@link SdkError} `TARGET_UNWRITABLE`: the target locale file could not be written because
1574
+ * of a file-system failure. The message names the target file and the file-system code, never the
1575
+ * internal temporary file.
1067
1576
  * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
1068
- * unsupported version.
1069
- * @throws {@link SdkError} `LOCK_CONTENDED`: this locale's write lock could not be acquired before
1070
- * its timeout, because another process is holding it.
1577
+ * unsupported version.
1578
+ * @throws `AdapterError`: the adapter itself refused the target locale file, on the read because it
1579
+ * is malformed or on the write because the entries cannot be represented in the configured format.
1580
+ * Its own code is preserved rather than remapped onto an {@link SdkErrorCode}.
1071
1581
  */
1072
1582
  declare function editEntry(input: EditEntryInput, deps?: EditEntryDeps): Promise<EditEntryResult>;
1073
1583
 
1074
- /**
1075
- * One key's placeholder and ICU integrity result for one target locale. Only ever computed for a
1076
- * "changed" key (a current source and a current target value both exist). `hasPlaceholders` is
1077
- * false when the source value carries no placeholders, in which case `matches` is trivially true
1078
- * and carries no signal on its own. `icuValid` is computed unconditionally, independent of
1079
- * `matches`: unlike `gateCandidateValue`, which stops at the first failing check, this is an
1080
- * information report and stays accurate even when the key already fails on placeholders; it is
1081
- * always true for a non-ICU format. Carries no source or target string value: only the boolean
1082
- * results and, on a placeholder mismatch, the specific tokens involved.
1083
- */
1584
+ /** One key's placeholder and ICU verdict in a {@link LocaleKeyIntegrity} report. */
1084
1585
  interface KeyIntegrityEntry {
1586
+ /** The key this verdict describes. */
1085
1587
  readonly key: string;
1588
+ /** Whether the source text contains any placeholders at all. When false, `matches` is trivially true. */
1086
1589
  readonly hasPlaceholders: boolean;
1590
+ /** True when the translation carries exactly the source's placeholders. */
1087
1591
  readonly matches: boolean;
1592
+ /** Placeholders present in the source but absent from the translation. */
1088
1593
  readonly missing: readonly string[];
1594
+ /** Placeholders present in the translation but not in the source. */
1089
1595
  readonly extra: readonly string[];
1596
+ /** True when the translation parses as a valid ICU message under the configured format. */
1090
1597
  readonly icuValid: boolean;
1091
1598
  }
1092
- /** One target locale's integrity entries for the keys checked against it. */
1599
+ /** One locale's per-key integrity verdicts. */
1093
1600
  interface LocaleKeyIntegrity {
1601
+ /** The target locale these verdicts describe. */
1094
1602
  readonly locale: string;
1603
+ /** Verdicts for the changed keys that exist in both the source and this locale. */
1095
1604
  readonly entries: readonly KeyIntegrityEntry[];
1096
1605
  }
1097
- /** Input for {@link keyIntegrity}: the validated config, which locales to check, and an optional key filter. */
1606
+ /** Input for {@link keyIntegrity}. */
1098
1607
  interface KeyIntegrityInput {
1099
- /** The validated configuration (typically from {@link loadConfig}). */
1608
+ /** The resolved project config, normally from {@link loadConfig}. */
1100
1609
  readonly config: VerbatraConfig;
1101
- /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
1610
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
1102
1611
  readonly cwd?: string;
1103
- /** Subset of target locales to check; defaults to all configured target locales. */
1612
+ /** Restrict the report to these target locales. Defaults to every configured target locale. */
1104
1613
  readonly locales?: readonly string[];
1105
- /** Restrict the check to these keys; only the ones that are "changed" for a locale are checked. Defaults to every changed key. */
1614
+ /** Restrict the report to these keys. Defaults to every key the diff reports as changed. */
1106
1615
  readonly keys?: readonly string[];
1107
1616
  }
1108
- /** Composition seam for {@link keyIntegrity}: inject a registry and a file system for tests. */
1617
+ /** Injectable dependencies for {@link keyIntegrity}. Every field has a working default. */
1109
1618
  interface KeyIntegrityDeps {
1619
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1110
1620
  readonly adapterRegistry?: AdapterRegistry;
1621
+ /** File-system port. Defaults to the real file system. */
1111
1622
  readonly fs?: SdkFs;
1112
1623
  }
1113
1624
  /**
1114
- * Runs the placeholder check and the ICU message-validity check for each selected target locale's
1115
- * "changed" keys (present with a current value on both sides, per core's `diffResources`) and
1116
- * reports a per-key result. Uses the adapter's own `comparePlaceholders` when present (the
1117
- * branch-aware ICU path), otherwise core's `checkPlaceholders` over the stored placeholder lists.
1118
- * `icuValid` is `adapter.validateMessage(target)`, computed unconditionally and independently of
1119
- * the placeholder result, so a target that is placeholder-valid but syntactically invalid ICU is
1120
- * still reported accurately. Read-only: it calls no provider, writes no file, and never touches
1121
- * the lock. Missing and orphaned keys are never checked, since one side's value does not exist for
1122
- * those.
1625
+ * Reports, per changed key, whether the existing translation still carries the source's
1626
+ * placeholders and still parses as valid ICU. It writes nothing and calls no provider.
1627
+ *
1628
+ * The scope is deliberately the changed keys rather than every key: a key whose source text has not
1629
+ * moved was already gated when it was written, so re-reporting it would bury the keys that a source
1630
+ * edit may have just invalidated. Only keys present in both the source and the target are judged,
1631
+ * since a missing translation has no placeholders to compare.
1632
+ *
1633
+ * This is the read-only counterpart to the gate that {@link editEntry} and
1634
+ * {@link retranslateEntry} enforce at write time, and the data behind a review dashboard's
1635
+ * per-key integrity indicator.
1636
+ *
1637
+ * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1638
+ * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1639
+ * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
1640
+ * error from a target file.
1123
1641
  *
1124
- * The returned entries never carry a source or target string value, only the boolean results and,
1125
- * on a placeholder mismatch, the specific tokens involved.
1642
+ * @param input - The config and the optional locale and key filters.
1643
+ * @param deps - Optional adapter registry and file-system overrides.
1644
+ * @returns One entry per requested locale, each holding its per-key verdicts.
1126
1645
  *
1127
- * @param input - The validated config, which locales to check, and an optional key filter.
1128
- * @param deps - Optional composition seams (registry, file system) for tests.
1129
- * @returns One entry per checked locale, each carrying the integrity result for its changed keys
1130
- * (narrowed to `input.keys` when supplied).
1131
- * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`,
1132
- * `LOCK_FILE_INVALID`, or `UNKNOWN_LOCALE`, with the same meanings as in {@link diff}.
1646
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1647
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1648
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
1649
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1650
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1651
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1652
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
1653
+ * unsupported version.
1654
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
1133
1655
  */
1134
1656
  declare function keyIntegrity(input: KeyIntegrityInput, deps?: KeyIntegrityDeps): Promise<readonly LocaleKeyIntegrity[]>;
1135
1657
 
1136
- /** Input for {@link keyValue}: the validated config and exactly one target locale/key pair. */
1658
+ /** Input for {@link keyValue}. */
1137
1659
  interface KeyValueInput {
1138
- /** The validated configuration (typically from {@link loadConfig}). */
1660
+ /** The resolved project config, normally from {@link loadConfig}. */
1139
1661
  readonly config: VerbatraConfig;
1140
- /** Directory the file pattern resolves against; defaults to cwd. */
1662
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
1141
1663
  readonly cwd?: string;
1142
- /** The target locale to read the current translation from. Must be a configured target locale. */
1664
+ /** The target locale to read the translation from. Must be a configured target locale. */
1143
1665
  readonly locale: string;
1144
- /** The source key to read. Must exist in the source resource. */
1666
+ /** The key to read. Must exist in the source resource. */
1145
1667
  readonly key: string;
1146
1668
  }
1147
- /** Composition seam for {@link keyValue}: inject a registry and a file system for tests. */
1669
+ /** Injectable dependencies for {@link keyValue}. Every field has a working default. */
1148
1670
  interface KeyValueDeps {
1671
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1149
1672
  readonly adapterRegistry?: AdapterRegistry;
1673
+ /** File-system port. Defaults to the real file system. */
1150
1674
  readonly fs?: SdkFs;
1151
1675
  }
1152
- /**
1153
- * The current source and target values for one key/locale pair. `target` is absent exactly when
1154
- * the key does not yet exist in that target locale; a present-but-empty-string target is a real
1155
- * value and is returned as such.
1156
- */
1676
+ /** One key's current source and target text, as returned by {@link keyValue}. */
1157
1677
  interface KeyValueResult {
1678
+ /** The key's text in the source locale. Always present, since a missing key is an error. */
1158
1679
  readonly source: string;
1680
+ /** The key's text in the requested target locale, or absent when it has not been translated yet. */
1159
1681
  readonly target?: string;
1160
1682
  }
1161
1683
  /**
1162
- * Reads a key's current source and target values for exactly one target locale. Read-only: it
1163
- * calls no provider, writes no file, and mutates nothing. Exposes only the current values on disk,
1164
- * never a previous one.
1684
+ * Reads one key's current source and target text. It writes nothing and calls no provider, and is
1685
+ * the read half of the single-key editing flow that {@link editEntry} and
1686
+ * {@link retranslateEntry} complete.
1687
+ *
1688
+ * The two sides are asymmetric on purpose: a key absent from the source is an error, because it
1689
+ * cannot be edited or retranslated, while a key absent from the target is normal and simply comes
1690
+ * back with no `target`.
1691
+ *
1692
+ * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1693
+ * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1694
+ * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
1695
+ * error from a target file.
1165
1696
  *
1166
- * `locale` and `key` are resolved fresh on every call, so a caller feeding an edit dialog with
1167
- * this result and then submitting through `editEntry` is always working from live data, never a
1168
- * cached snapshot.
1697
+ * @param input - The config, locale, and key to read.
1698
+ * @param deps - Optional adapter registry and file-system overrides.
1699
+ * @returns The key's source text and, when present, its current translation.
1169
1700
  *
1170
- * @param input - The validated config, the target locale, and the source key to read.
1171
- * @param deps - Optional composition seams (registry, file system) for tests.
1172
- * @returns The current source value and, when present, the current target value.
1173
1701
  * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1174
- * @throws {@link SdkError} `UNKNOWN_LOCALE`: `locale` is not among `config.targetLocales`.
1175
- * @throws {@link SdkError} `SOURCE_UNREADABLE` / `SOURCE_INVALID`: the source locale file is absent
1176
- * or unreadable.
1177
- * @throws {@link SdkError} `UNKNOWN_KEY`: `key` does not exist in the source resource.
1702
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: the requested locale is not a configured target locale.
1703
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1704
+ * cannot be combined, or the locale has no valid path spelling under that style.
1705
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1706
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1707
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1708
+ * @throws {@link SdkError} `UNKNOWN_KEY`: the key is not present in the source resource.
1178
1709
  */
1179
1710
  declare function keyValue(input: KeyValueInput, deps?: KeyValueDeps): Promise<KeyValueResult>;
1180
1711
 
1181
1712
  /**
1182
- * One locale file reduced to a content hash per key, taken at one point in time. Two snapshots of
1183
- * the same file can be compared with {@link diffLocaleSnapshots} to count what changed between
1184
- * them.
1713
+ * One locale file reduced to a per-key content hash. Holding hashes rather than values keeps a
1714
+ * long-lived watcher's memory flat in the size of the translations and makes comparison a cheap
1715
+ * equality check.
1185
1716
  */
1186
1717
  interface LocaleFileSnapshot {
1187
- /** The locale this snapshot was read for (the configured source locale or a target locale). */
1718
+ /** The locale this snapshot was taken for. */
1188
1719
  readonly locale: string;
1189
- /** Content hash per key, from core's `contentHash`. */
1720
+ /** Content hash per key, in the order the adapter read them. */
1190
1721
  readonly hashes: ReadonlyMap<string, string>;
1191
1722
  }
1192
- /** Input for {@link readLocaleFileSnapshot}: the validated config and which locale to snapshot. */
1723
+ /** Input for {@link readLocaleFileSnapshot}. */
1193
1724
  interface ReadLocaleFileSnapshotInput {
1194
- /** The validated configuration (typically from {@link loadConfig}). */
1725
+ /** The resolved project config, normally from {@link loadConfig}. */
1195
1726
  readonly config: VerbatraConfig;
1196
- /** The locale to snapshot: the configured source locale or any configured target locale. */
1727
+ /**
1728
+ * The locale file to snapshot. Unlike most entry points this is not checked against the
1729
+ * configured target locales, so the source locale can be snapshotted too.
1730
+ */
1197
1731
  readonly locale: string;
1198
- /** Directory the file pattern resolves against; defaults to cwd. */
1732
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
1199
1733
  readonly cwd?: string;
1200
1734
  }
1201
- /** Composition seam for {@link readLocaleFileSnapshot}: inject a registry and a file system for tests. */
1735
+ /** Injectable dependencies for {@link readLocaleFileSnapshot}. Every field has a working default. */
1202
1736
  interface ReadLocaleFileSnapshotDeps {
1737
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1203
1738
  readonly adapterRegistry?: AdapterRegistry;
1739
+ /** File-system port. Defaults to the real file system. */
1204
1740
  readonly fs?: SdkFs;
1205
1741
  }
1206
1742
  /**
1207
- * Reads one locale file through the configured adapter and reduces it to a content hash per key.
1208
- * Uses the same tolerant read path as {@link readTarget}: a file that does not exist yet reads as
1209
- * an empty snapshot rather than throwing, so a caller can establish a baseline before a locale's
1210
- * first write.
1743
+ * Reads one locale file as a per-key content hash. Together with {@link diffLocaleSnapshots} this
1744
+ * is the building block for a live-refresh watcher: hold a snapshot, re-read on a file event, and
1745
+ * compare the two to learn what actually changed.
1746
+ *
1747
+ * A locale file that does not exist yet reads as an empty snapshot rather than an error, so a
1748
+ * watcher can start before the first translation run has written anything.
1749
+ *
1750
+ * Note that this entry point does not validate the locale against the configured targets, and that
1751
+ * a malformed target file surfaces the adapter's own error and code rather than a wrapped
1752
+ * {@link SdkError}, because no source-file contract is being asserted here. Its message names the
1753
+ * offending locale and the resolved path.
1754
+ *
1755
+ * @param input - The config and the locale to snapshot.
1756
+ * @param deps - Optional adapter registry and file-system overrides.
1757
+ * @returns The locale's per-key content hashes.
1211
1758
  *
1212
- * @param input - The validated config, the locale to snapshot, and the directory to resolve against.
1213
- * @param deps - Optional composition seams (registry, file system) for tests.
1214
- * @returns The locale's per-key content hash snapshot.
1215
- * @throws {@link SdkError} `UNKNOWN_FORMAT` when the configured format has no registered adapter;
1216
- * rejects with the adapter's own error when the file exists but its content is malformed.
1759
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1760
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1761
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
1762
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1217
1763
  */
1218
1764
  declare function readLocaleFileSnapshot(input: ReadLocaleFileSnapshotInput, deps?: ReadLocaleFileSnapshotDeps): Promise<LocaleFileSnapshot>;
1219
- /** A locale file's added, changed, and removed key counts between two of its own snapshots. Counts only, never key names. */
1765
+ /** How many keys were added, changed, and removed between two {@link LocaleFileSnapshot}s. */
1220
1766
  interface LocaleSnapshotDelta {
1767
+ /** Keys present in the current snapshot but not the previous one. */
1221
1768
  readonly added: number;
1769
+ /** Keys present in both snapshots whose content hash differs. */
1222
1770
  readonly changed: number;
1771
+ /** Keys present in the previous snapshot but not the current one. */
1223
1772
  readonly removed: number;
1224
1773
  }
1225
1774
  /**
1226
- * Compares two snapshots of the same locale file, taken at different points in time, and counts
1227
- * how many keys were added, changed, or removed. A direct key-to-hash comparison rather than
1228
- * core's `diffResources`: that function's source/target/baseline shape exists for cross-resource
1229
- * comparison, and forcing a same-file, two-points-in-time comparison through it would need a
1230
- * confusing relabeling (missing becomes removed, orphaned becomes added).
1775
+ * Compares two {@link LocaleFileSnapshot}s and counts what moved. It is pure, synchronous, and
1776
+ * touches no file system, so a watcher can call it on every file event without cost.
1777
+ *
1778
+ * The two snapshots are compared by key and content hash alone; neither the locale names nor the
1779
+ * order are consulted, so passing snapshots of two different locales compares them rather than
1780
+ * failing.
1231
1781
  *
1232
- * @param previous - The earlier snapshot of the file.
1233
- * @param current - The later snapshot of the same file.
1234
- * @returns The counts of keys added, changed, and removed between the two snapshots.
1782
+ * @param previous - The earlier snapshot.
1783
+ * @param current - The later snapshot.
1784
+ * @returns The added, changed, and removed key counts.
1235
1785
  */
1236
1786
  declare function diffLocaleSnapshots(previous: LocaleFileSnapshot, current: LocaleFileSnapshot): LocaleSnapshotDelta;
1237
1787
 
1238
- /** Per-locale key count and drift against the source and target files, from the recorded lock baseline. */
1788
+ /** One locale's lock-file baseline and the drift measured against it. */
1239
1789
  interface LockLocaleState {
1240
- /** The target locale this entry reports on. */
1790
+ /** The target locale this entry describes. */
1241
1791
  readonly locale: string;
1242
- /** Number of keys the lock records a baseline for, in this locale. */
1792
+ /** How many keys the lock-file records a baseline hash for in this locale. */
1243
1793
  readonly keyCount: number;
1244
- /** Keys present in source but absent from the target. */
1794
+ /** Number of source keys with no translation in this locale yet. */
1245
1795
  readonly missing: number;
1246
- /** Keys whose source changed since the target was last translated. */
1796
+ /** Number of keys whose source text changed since the recorded baseline. */
1247
1797
  readonly stale: number;
1248
- /** Keys whose recorded baseline still matches the source. */
1798
+ /** Number of keys whose translation still matches the recorded baseline. */
1249
1799
  readonly upToDate: number;
1250
1800
  }
1251
1801
  /**
1252
- * The lock-file's existence, version, and per-locale drift. `exists` is the outcome of an explicit
1253
- * probe of the lock-file path, never inferred from {@link readLockFile}'s missing-file degrade, so
1254
- * "no lock-file yet" and "an empty but present lock-file" stay distinguishable.
1802
+ * The result of {@link lockState}. The absence of a lock-file is a first-class state rather than an
1803
+ * error, because a project that has never been translated legitimately has none.
1255
1804
  */
1256
1805
  type LockStateResult = {
1806
+ /** No lock-file exists yet, so there is no baseline to report against. */
1257
1807
  readonly exists: false;
1258
1808
  } | {
1809
+ /** A lock-file exists and was read successfully. */
1259
1810
  readonly exists: true;
1811
+ /** The lock-file's schema version. */
1260
1812
  readonly version: number;
1813
+ /** Per-locale baseline sizes and drift, in configured target order. */
1261
1814
  readonly locales: readonly LockLocaleState[];
1262
1815
  };
1263
- /** Input for {@link lockState}: the validated config and which locales to report on. */
1816
+ /** Input for {@link lockState}. */
1264
1817
  interface LockStateInput {
1265
- /** The validated configuration (typically from {@link loadConfig}). */
1818
+ /** The resolved project config, normally from {@link loadConfig}. */
1266
1819
  readonly config: VerbatraConfig;
1267
- /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
1820
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
1268
1821
  readonly cwd?: string;
1269
- /** Subset of target locales to report on; defaults to all configured target locales. */
1822
+ /** Restrict the report to these target locales. Defaults to every configured target locale. */
1270
1823
  readonly locales?: readonly string[];
1271
1824
  }
1272
- /** Composition seam for {@link lockState}: inject a registry and a file system for tests. */
1825
+ /** Injectable dependencies for {@link lockState}. Every field has a working default. */
1273
1826
  interface LockStateDeps {
1827
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1274
1828
  readonly adapterRegistry?: AdapterRegistry;
1829
+ /** File-system port. Defaults to the real file system. */
1275
1830
  readonly fs?: SdkFs;
1276
1831
  }
1277
1832
  /**
1278
- * Report the lock-file's existence, version, and per-locale drift (key count from the recorded
1279
- * baseline, plus missing, stale, and up-to-date counts against the current source and target),
1280
- * without calling a provider, writing any file, or mutating the lock. `exists` comes from an
1281
- * explicit probe of the lock-file path: when it is absent, the result is `{ exists: false }` and
1282
- * no source or target file is read, since there is no recorded baseline to report drift against.
1833
+ * Reports the lock-file's existence, version, and the per-locale drift measured against its
1834
+ * baseline. It writes nothing and calls no provider.
1835
+ *
1836
+ * Where {@link check} answers "is the project in sync", this answers "what does the lock-file
1837
+ * actually record", which is what a diagnostic view needs when the two disagree: a locale with
1838
+ * translations present but a `keyCount` of zero, for instance, means the files were written outside
1839
+ * verbatra and have no baseline.
1840
+ *
1841
+ * When no lock-file exists the call returns `exists: false` rather than throwing, and does no
1842
+ * further reading.
1843
+ *
1844
+ * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1845
+ * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1846
+ * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
1847
+ * error from a target file.
1848
+ *
1849
+ * @param input - The config and the optional locale filter.
1850
+ * @param deps - Optional adapter registry and file-system overrides.
1851
+ * @returns The lock-file's version and per-locale baseline state, or `exists: false`.
1283
1852
  *
1284
- * @param input - The validated config and which locales to report on.
1285
- * @param deps - Optional composition seams (registry, file system) for tests.
1286
- * @returns The lock-file's existence and, when present, its version and per-locale drift.
1287
- * @throws {@link SdkError} `UNKNOWN_LOCALE` when a requested locale is not among the configured
1288
- * target locales; when the lock-file exists, also `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`,
1289
- * `SOURCE_INVALID`, or `LOCK_FILE_INVALID` with the same meanings as in `translate`.
1853
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
1854
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
1855
+ * unsupported version.
1856
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1857
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1858
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
1859
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1860
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1861
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1290
1862
  */
1291
1863
  declare function lockState(input: LockStateInput, deps?: LockStateDeps): Promise<LockStateResult>;
1292
1864
 
1293
- /** Builds the provider from its config. Injectable so tests stay offline. */
1865
+ /**
1866
+ * Builds a {@link TranslationProvider} from the config's `provider` block. Passed as
1867
+ * `deps.createProvider` to {@link translate}, {@link watch}, and {@link retranslateEntry}, it is the
1868
+ * seam for injecting a stub in a test or a provider the SDK does not ship.
1869
+ *
1870
+ * The default implementation dispatches on the provider ID and reads the API key from the
1871
+ * environment. A factory that throws is reported as `PROVIDER_CONSTRUCTION_FAILED`.
1872
+ */
1294
1873
  type CreateProvider = (config: ProviderConfig) => TranslationProvider;
1295
1874
 
1296
- /** Input for {@link retranslateEntry}: the validated config and exactly one target locale/key pair. */
1875
+ /** Input for {@link retranslateEntry}. */
1297
1876
  interface RetranslateEntryInput {
1298
- /** The validated configuration (typically from {@link loadConfig}). */
1877
+ /** The resolved project config, normally from {@link loadConfig}. */
1299
1878
  readonly config: VerbatraConfig;
1300
- /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
1879
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
1301
1880
  readonly cwd?: string;
1302
- /** The target locale to retranslate the key into. Must be a configured target locale. */
1881
+ /** The target locale to retranslate into. Must be a configured target locale. */
1303
1882
  readonly locale: string;
1304
- /** The source key to retranslate. Must exist in the source resource. */
1883
+ /** The key to retranslate. Must exist in the source resource. */
1305
1884
  readonly key: string;
1306
1885
  }
1307
- /** Composition seam for {@link retranslateEntry}: inject a registry, a provider builder, and a file system for tests. */
1886
+ /** Injectable dependencies for {@link retranslateEntry}. Every field has a working default. */
1308
1887
  interface RetranslateEntryDeps {
1888
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1309
1889
  readonly adapterRegistry?: AdapterRegistry;
1890
+ /** Provider factory. Defaults to constructing the provider named in the config. */
1310
1891
  readonly createProvider?: CreateProvider;
1892
+ /** File-system port. Defaults to the real file system. */
1311
1893
  readonly fs?: SdkFs;
1312
1894
  }
1313
1895
  /**
1314
- * The two-armed result of one retranslate attempt: `accepted` carries the newly written value and
1315
- * any derived "needs review" reasons; a rejection carries the candidate value and which
1316
- * {@link gateCandidateValue} check failed it, without writing anything.
1896
+ * The outcome of {@link retranslateEntry}. As with {@link EditEntryResult}, a value refused by the
1897
+ * integrity gate is reported as data rather than thrown, because it is an expected outcome of
1898
+ * asking a provider for a fresh translation.
1317
1899
  */
1318
1900
  type RetranslateEntryResult = {
1901
+ /** The provider's value passed the integrity gate and was written. */
1319
1902
  readonly accepted: true;
1903
+ /** The newly translated value now stored for the key. */
1320
1904
  readonly value: string;
1905
+ /**
1906
+ * Quality signals the provider layer raised for this value, such as a length-ratio outlier or
1907
+ * a value identical to the source. Empty when nothing was flagged. The value is written
1908
+ * either way; these are advisory.
1909
+ */
1321
1910
  readonly reviewReasons: readonly ReviewReasonCode[];
1322
1911
  } | {
1912
+ /** The value was refused; nothing was written and the previous translation is intact. */
1323
1913
  readonly accepted: false;
1914
+ /** Which integrity rule the provider's value broke. */
1324
1915
  readonly reason: IntegrityGateReason;
1916
+ /** The rejected value, echoed back so a UI can show what was refused. */
1325
1917
  readonly value: string;
1326
1918
  };
1327
1919
  /**
1328
- * Retranslate exactly one key for exactly one target locale: a single-entry `translateBatch` call
1329
- * through the same provider registry `translate()` uses, gated through the shared
1330
- * {@link gateCandidateValue} before anything reaches disk. On acceptance, writes the target locale
1331
- * file (merging just this one key into its current entries, every other key untouched) and updates
1332
- * the lock entry for this key only (see {@link updateLockFileLocale}'s `"merge"` mode); on rejection,
1333
- * writes nothing. Reuses exactly the building blocks `translate()` already uses
1334
- * (`selectProvider`/`provider.translateBatch`) and nothing else: no bespoke per-provider plumbing.
1335
- * Always calls the provider (it never consults the cache for a hit, since its whole purpose is a
1336
- * fresh translation), then feeds the accepted value into the translation-memory cache best-effort so
1337
- * a later run can reuse it; a cache failure is swallowed and never fails the retranslate.
1338
- *
1339
- * `locale` and `key` are resolved fresh on every call, never cached: `locale` against
1340
- * `config.targetLocales` (`UNKNOWN_LOCALE`), `key` against the source resource's own keys, read
1341
- * fresh from disk (`UNKNOWN_KEY`), via a `Map`-backed lookup, never `key in obj` or
1342
- * `obj[key] !== undefined` against a plain object.
1343
- *
1344
- * The target-file write and the lock-file update run inside the same held
1345
- * `withLocaleWriteLock(cwd, locale, fs, ...)` critical section as the provider call itself, so no
1346
- * second writer for this locale can ever observe the target file updated but the lock entry not
1347
- * yet, or vice versa. The same locking also covers `translate()`/`watch()` and `importWorkbook()`.
1348
- *
1349
- * Not atomic across the two writes for a single caller's own run: the target locale file is
1350
- * written before the lock entry is updated. If `updateLockFileLocale` then throws
1351
- * (`LOCK_FILE_INVALID` for a corrupt lock-file, or `LOCK_CONTENDED` if its own internal lock-file
1352
- * guard cannot be acquired before its timeout), the target file already carries the new value
1353
- * while the lock still records the old hash, and the thrown error does not imply nothing was
1354
- * saved. The same ordering, and the same partial-write shape on that rare path, also exists in
1355
- * `translate()` and `importWorkbook()`.
1356
- *
1357
- * @param input - The validated config, the target locale, and the source key to retranslate.
1358
- * @param deps - Optional composition seams (registry, provider builder, file system) for tests.
1359
- * @returns The two-armed {@link RetranslateEntryResult}.
1920
+ * Re-runs the configured provider for a single key and saves the result. This is the paid
1921
+ * counterpart to {@link editEntry}: it calls the provider and therefore spends tokens, which is why
1922
+ * a UI should gate it behind an explicit user action.
1923
+ *
1924
+ * The returned value goes through the same integrity gate as a full run, so a translation that
1925
+ * loses a placeholder or breaks ICU syntax is refused and nothing is written. Provider quality
1926
+ * signals are surfaced on an accepted result as `reviewReasons` rather than blocking the write.
1927
+ *
1928
+ * The locale's write lock is taken before the provider is called and held across it, covering the
1929
+ * whole read-translate-write cycle, so a slow provider keeps the lock for the length of its call
1930
+ * and a concurrent {@link translate} run on that locale waits. The lock is held for a refused
1931
+ * translation too, since the gate runs inside it. An accepted value then updates the lock-file
1932
+ * baseline and feeds the translation memory, so a later {@link translate} run sees the key as up
1933
+ * to date.
1934
+ *
1935
+ * Note that the target locale file surfaces the adapter's own error and code rather than a wrapped
1936
+ * {@link SdkError}, on the write as well as on the read, because only the source read is wrapped.
1937
+ * A malformed target file fails the read with a message naming the offending locale and the
1938
+ * resolved path. The write raises the adapter's error too, when the entries cannot be represented
1939
+ * in the configured format or the existing destination file cannot be read back to be updated in
1940
+ * place, and that error is re-thrown unchanged so its own code survives for a caller that maps
1941
+ * adapter codes to its own copy. A caller that maps SDK codes should be ready for an unrecognized
1942
+ * error from a target file on either path.
1943
+ *
1944
+ * @param input - The config, locale, and key to retranslate.
1945
+ * @param deps - Optional adapter registry, provider factory, and file-system overrides.
1946
+ * @returns Whether the new value was accepted, with review reasons or the rejection reason.
1947
+ *
1360
1948
  * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1361
- * @throws {@link SdkError} `UNKNOWN_LOCALE`: `locale` is not among `config.targetLocales`.
1362
- * @throws {@link SdkError} `SOURCE_UNREADABLE` / `SOURCE_INVALID`: the source locale file is absent
1363
- * or unreadable.
1364
- * @throws {@link SdkError} `UNKNOWN_KEY`: `key` does not exist in the source resource.
1365
- * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw (wraps a
1366
- * missing `*_API_KEY` as `MISSING_API_KEY`).
1367
- * @throws {@link ProviderError} the provider call itself failed, or returned no value at all for
1368
- * this key (`INVALID_RESPONSE`).
1949
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: the requested locale is not a configured target locale.
1950
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1951
+ * cannot be combined, or the locale has no valid path spelling under that style.
1952
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1953
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1954
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1955
+ * @throws {@link SdkError} `UNKNOWN_KEY`: the key is not present in the source resource.
1956
+ * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider could not be constructed,
1957
+ * most often because its API key environment variable is unset.
1958
+ * @throws {@link SdkError} `LOCK_CONTENDED`: the locale's write lock could not be acquired before
1959
+ * the timeout elapsed.
1960
+ * @throws {@link SdkError} `TARGET_UNWRITABLE`: the target locale file could not be written because
1961
+ * of a file-system failure. The message names the target file and the file-system code, never the
1962
+ * internal temporary file.
1369
1963
  * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
1370
- * unsupported version.
1371
- * @throws {@link SdkError} `LOCK_CONTENDED`: this locale's write lock could not be acquired before
1372
- * its timeout, because another process (another CLI run, or a second Studio write) is holding it.
1964
+ * unsupported version.
1965
+ * @throws `AdapterError`: the adapter itself refused the target locale file, on the read because it
1966
+ * is malformed or on the write because the entries cannot be represented in the configured format.
1967
+ * Its own code is preserved rather than remapped onto an {@link SdkErrorCode}.
1968
+ * @throws `ProviderError` `INVALID_RESPONSE`: the provider returned no value for the key. Provider
1969
+ * transport and rate-limit failures propagate as `ProviderError` too, since a single-key call has
1970
+ * no per-locale summary to record them on.
1373
1971
  */
1374
1972
  declare function retranslateEntry(input: RetranslateEntryInput, deps?: RetranslateEntryDeps): Promise<RetranslateEntryResult>;
1375
1973
 
1376
- /** A stable code for an SDK-originated notice (not a provider notice). */
1974
+ /**
1975
+ * Conditions the SDK itself reports on a locale, as opposed to the {@link ProviderNotice} codes a
1976
+ * provider raises. Each marks a run that completed but did something a caller may want to know
1977
+ * about.
1978
+ *
1979
+ * - `PLURAL_CATEGORIES_INCOMPLETE`: plural generation could not produce every category the target
1980
+ * language requires, so the entry is written with the categories that were produced.
1981
+ * - `SUB_BATCH_FAILED`: one sub-batch of a locale failed while others succeeded. The locale is
1982
+ * reported as `partial` rather than failed.
1983
+ * - `BLANK_ROW_BASELINE_RETAINED`: an imported handoff row was blank, so the existing translation
1984
+ * and its lock-file baseline were kept rather than being erased.
1985
+ * - `BUDGET_TOKENS_EXCEEDED`: the configured token budget was passed. Under `warn` the run
1986
+ * continues; under `stop` the remaining keys are withheld.
1987
+ * - `CACHE_VERSION_UNRECOGNIZED`: the translation memory is at a version this release does not
1988
+ * understand, so it was ignored rather than trusted.
1989
+ */
1377
1990
  type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED" | "CACHE_VERSION_UNRECOGNIZED";
1378
- /** Summed token usage across every provider call in a scope (one locale, or the whole run). */
1991
+ /**
1992
+ * Token usage as reported by the provider. Absent when the provider does not report usage, which is
1993
+ * the case for machine-translation APIs such as DeepL that do not bill in tokens.
1994
+ */
1379
1995
  interface UsageSummary {
1996
+ /** Tokens consumed by the prompts sent to the provider. */
1380
1997
  readonly inputTokens: number;
1998
+ /** Tokens consumed by the provider's responses. */
1381
1999
  readonly outputTokens: number;
1382
2000
  }
1383
- /** The configured token-budget behavior: warn and continue, or stop making further provider calls. */
1384
- type BudgetBehavior = "warn" | "stop";
1385
2001
  /**
1386
- * The run-wide token-budget outcome, present on {@link RunSummary} only when `maxTokens` is configured.
1387
- * `supported: false` means the configured provider never reported usage in this run (a token-less
1388
- * provider such as DeepL, or a dry-run): the guardrail is honestly inert, `tokensUsed` stays `0`, and
1389
- * `exceeded` stays `false`, never a false trip.
2002
+ * What a run does when it passes its token budget: `warn` finishes the work and reports the
2003
+ * overrun, `stop` withholds the remaining keys.
1390
2004
  */
2005
+ type BudgetBehavior = "warn" | "stop";
2006
+ /** The token budget in force for a run, and how much of it was actually consumed. */
1391
2007
  interface RunBudget {
1392
- /** The configured ceiling. */
2008
+ /** The configured ceiling, in tokens. */
1393
2009
  readonly maxTokens: number;
1394
- /** The configured behavior once the ceiling is reached. */
2010
+ /** Whether passing the ceiling warns or stops the run. */
1395
2011
  readonly behavior: BudgetBehavior;
1396
- /** Whether the configured provider reported usage at all this run. */
2012
+ /**
2013
+ * Whether the configured provider reports usage at all. When false the budget cannot be enforced,
2014
+ * because there is nothing to count.
2015
+ */
1397
2016
  readonly supported: boolean;
1398
- /** Cumulative input plus output tokens across the run so far. */
2017
+ /** Tokens consumed across the whole run so far. */
1399
2018
  readonly tokensUsed: number;
1400
- /** Whether `tokensUsed` reached or passed `maxTokens` at any point this run. */
2019
+ /** True once `tokensUsed` passed `maxTokens`. */
1401
2020
  readonly exceeded: boolean;
1402
2021
  }
1403
- /**
1404
- * A notice raised by the SDK itself (not a provider), structurally identical to a {@link ProviderNotice}
1405
- * so both share the {@link LocaleSummary.notices} channel. Carries only a stable code and a static,
1406
- * secret-free message; never a key value or translatable content.
1407
- */
2022
+ /** A condition the SDK reported on a locale. See {@link SdkNoticeCode}. */
1408
2023
  interface SdkNotice {
1409
- /** The stable {@link SdkNoticeCode} for what the SDK is reporting. */
2024
+ /** The stable notice code. Branch on this, not on the message. */
1410
2025
  readonly code: SdkNoticeCode;
1411
- /** A static, safe description; never a key or translatable content. */
2026
+ /** A human-readable description of the condition. */
1412
2027
  readonly message: string;
1413
2028
  }
1414
- /** A notice on a locale summary: either a provider-emitted notice or an SDK-emitted one. */
2029
+ /**
2030
+ * Any notice attached to a locale: either one the provider raised, such as a downgraded formality
2031
+ * or an ignored glossary, or one the SDK raised. Discriminate on `code`.
2032
+ */
1415
2033
  type LocaleNotice = ProviderNotice | SdkNotice;
1416
- /** One key flagged for human review, and every reason code that applies to it. */
2034
+ /**
2035
+ * A translated key the provider layer flagged as worth a human look. The translation was still
2036
+ * written; these are advisory quality signals, not rejections.
2037
+ */
1417
2038
  interface NeedsReviewEntry {
1418
- /** The flagged key. */
2039
+ /** The key that was flagged. */
1419
2040
  readonly key: string;
1420
- /** Every {@link ReviewReasonCode} that applies to this key. */
2041
+ /** Why it was flagged. A key can carry more than one reason. */
1421
2042
  readonly reasons: readonly ReviewReasonCode[];
1422
2043
  }
1423
- /**
1424
- * One row a workbook import could not read: the 1-based row number, the file line for a delimited
1425
- * import, and the header label of the offending column. Carries no cell content, so untrusted workbook
1426
- * text never reaches the summary.
1427
- */
2044
+ /** A row of an imported handoff that could not be read. Reported rather than aborting the import. */
1428
2045
  interface MalformedRowReport {
1429
- /** The 1-based worksheet row number of the malformed row. */
2046
+ /** The row's 1-based index within its sheet or file. */
1430
2047
  readonly row: number;
1431
2048
  /**
1432
- * The 1-based file line the malformed record starts on. Present only for a delimited (csv or tsv)
1433
- * import; an xlsx workbook has rows rather than lines. See {@link DuplicateKeyReport.line} for why
1434
- * both numbers are reported.
2049
+ * The 1-based physical line in the source file, for delimited formats where a quoted value can
2050
+ * span several lines. Absent for `.xlsx`, which has rows but no lines.
1435
2051
  */
1436
2052
  readonly line?: number;
1437
- /** The header label of the column the row was rejected on (for example "Status"). */
2053
+ /** The column whose value was missing or unusable. */
1438
2054
  readonly column: string;
1439
2055
  }
1440
2056
  /**
1441
- * One duplicate-key conflict a workbook import found in a locale's sheet: the duplicated key and the
1442
- * 1-based worksheet row number of the later (losing) occurrence. The first occurrence is the winner.
2057
+ * A key that appeared more than once in an imported handoff. The first occurrence wins and the rest
2058
+ * are reported here, so a translator who duplicated a row learns which value was actually used.
1443
2059
  */
1444
2060
  interface DuplicateKeyReport {
1445
- /** The key that appeared more than once in the locale's sheet. */
2061
+ /** The key that appeared more than once. */
1446
2062
  readonly key: string;
1447
- /** The 1-based worksheet row number of the later occurrence that lost to the first. */
2063
+ /** The 1-based row index of the ignored occurrence. */
1448
2064
  readonly row: number;
1449
- /**
1450
- * The 1-based file line this later occurrence starts on. Present only for a delimited (csv or tsv)
1451
- * import.
1452
- *
1453
- * A delimited record holding a quoted line break covers one spreadsheet row but several editor
1454
- * lines, so `row` is the number a translator sees in a spreadsheet and `line` the one they see in a
1455
- * text editor. Both are reported so the position holds whichever tool they opened the file in.
1456
- */
2065
+ /** The 1-based physical line of the ignored occurrence, for delimited formats. Absent for `.xlsx`. */
1457
2066
  readonly line?: number;
1458
2067
  }
1459
- /** Structured outcome for one target locale; surfaced as data on the run, never thrown. */
2068
+ /**
2069
+ * Everything that happened for one locale during a run. The key lists are disjoint accounts of what
2070
+ * became of each key, so a caller can reconstruct the whole run without re-reading any file.
2071
+ *
2072
+ * A locale that failed outright still appears here with `status: "failed"` and an `error`, rather
2073
+ * than the whole run throwing. That is the central contract of {@link translate}: one unreachable
2074
+ * provider or one unwritable file does not discard the locales that succeeded.
2075
+ */
1460
2076
  interface LocaleSummary {
1461
- /** The target locale this summary is for. */
2077
+ /** The target locale this summary describes. */
1462
2078
  readonly locale: string;
1463
2079
  /**
1464
- * This locale's honest run status (a non-success does not abort the run). `"succeeded"` when
1465
- * nothing was withheld, including a genuine no-op with no candidate keys at all. `"partial"` when
1466
- * at least one key was accepted and written (a {@link LocaleSummary.translated} key or a
1467
- * {@link LocaleSummary.generated} plural form) but at least one was withheld (any of
1468
- * {@link LocaleSummary.integrityMismatches}, {@link LocaleSummary.providerFailures}, or
1469
- * {@link LocaleSummary.budgetWithheld} non-empty). `"failed"` when the locale had candidate keys
1470
- * but accepted nothing at all (neither translated nor generated) while withholding at least one.
1471
- * Withheld keys keep their prior lock hash and retry next run.
1472
- *
1473
- * A workbook import's structural findings, {@link LocaleSummary.unfilled},
1474
- * {@link LocaleSummary.malformedRows}, and {@link LocaleSummary.duplicateKeys}, deliberately do NOT
1475
- * feed this status: they are surfaced in their own lists (and the CLI) but are not withholdings of
1476
- * an attempted translation, so a locale that dropped a malformed row, collapsed a duplicate key, or
1477
- * left a row unfilled while accepting its other rows can still be `"succeeded"`.
1478
- *
1479
- * This is a settled decision, not an omission. Recording the reasoning here so the question stops
1480
- * recurring:
1481
- *
1482
- * 1. Two questions, two commands. `import`'s exit code answers "did the import apply what the
1483
- * translator gave me". `check` and `diff` answer "is this project fully translated", and both
1484
- * already exit non-zero or report pending in every one of these cases. Folding pending human
1485
- * work into the import exit code duplicates an existing gate rather than adding one.
1486
- * 2. It would break the staged handoff. With `de` and `fr` configured and only `de` filled in,
1487
- * failing on unfilled work makes `fr` fail and takes the whole import with it, even though `de`
1488
- * imported perfectly. Locale-at-a-time handoff is this feature's most common workflow.
1489
- * 3. {@link LocaleSummary.malformedRows} structurally cannot signal lost work. The Status column is
1490
- * the only reachable rejection in the workbook reader; every other field is an unconstrained
1491
- * string or a tolerant parse. So a row is malformed on its Status cell alone, entirely
1492
- * independently of whether its Translation cell was filled. An untouched sheet with a mangled
1493
- * Status column (spreadsheet autocorrect, a CAT-tool round trip) is all-malformed with zero lost
1494
- * work. A bucket that cannot distinguish dropped work from absent work cannot honestly drive a
1495
- * failure status.
1496
- * 4. {@link LocaleSummary.duplicateKeys} never could: the first occurrence wins and the intent is
1497
- * honoured.
2080
+ * `succeeded` when everything asked for was done, `partial` when some keys were translated and
2081
+ * others were not, and `failed` when the locale produced no usable result.
1498
2082
  */
1499
2083
  readonly status: "succeeded" | "partial" | "failed";
1500
- /**
1501
- * Keys translated and written this run. In dry-run, the keys that would be translated
1502
- * (the provider is not called and nothing is written).
1503
- */
2084
+ /** Keys newly translated by the provider in this run. */
1504
2085
  readonly translated: readonly string[];
1505
- /** Keys already up to date, left unchanged this run. */
2086
+ /** Keys already up to date against the lock-file baseline, so no provider call was made. */
1506
2087
  readonly unchanged: readonly string[];
1507
- /** Target keys with no corresponding source key (candidates for removal). Reported regardless of pruning. */
2088
+ /** Keys present in this locale but no longer in the source. Reported, and removed only when pruning. */
1508
2089
  readonly orphaned: readonly string[];
1509
- /**
1510
- * Orphaned keys actually removed this run because pruning was on. In a dry-run with pruning on, the keys
1511
- * that would be removed. Empty when pruning is off (the orphans then survive and are reported in
1512
- * `orphaned` only). A subset of `orphaned`; never includes a source-present key.
1513
- */
2090
+ /** Orphaned keys that were actually removed, which happens only when the run was asked to prune. */
1514
2091
  readonly pruned: readonly string[];
1515
- /** Source keys flagged invalid-ICU that were skipped for translation this run. */
1516
- readonly invalidIcuSource: readonly string[];
1517
2092
  /**
1518
- * Keys served from the local translation-memory cache instead of the provider: their source content
1519
- * was unchanged (possibly under a renamed key or shared with another key) and the cached value passed
1520
- * the integrity gate, so no provider request was made for them. They are written and lock-advanced
1521
- * exactly like a translated key, but kept distinct from {@link LocaleSummary.translated} because they
1522
- * cost no provider usage. Always present, empty by default; empty in a dry-run and when the cache is
1523
- * bypassed. Sorted by key.
2093
+ * Keys whose source text is not valid ICU. They are skipped rather than sent to the provider,
2094
+ * because a broken source message cannot yield a sound translation.
1524
2095
  */
2096
+ readonly invalidIcuSource: readonly string[];
2097
+ /** Keys served from the translation memory instead of the provider, and so not paid for. */
1525
2098
  readonly cacheHits: readonly string[];
1526
2099
  /**
1527
- * Translated keys that failed the placeholder-integrity check and were withheld. Never includes a
1528
- * key withheld because the provider call itself failed; see {@link LocaleSummary.providerFailures}
1529
- * for that case.
2100
+ * Keys whose translation was refused by the integrity gate, for instance because it dropped a
2101
+ * placeholder. The previous translation, if any, is left untouched.
1530
2102
  */
1531
2103
  readonly integrityMismatches: readonly string[];
1532
- /**
1533
- * Keys withheld because nothing was translated for them this run, as distinct from a translation
1534
- * that came back and failed the placeholder-integrity check. This covers two causes: the provider
1535
- * call itself failed (for example a revoked API key, a rate limit, or a network timeout), in which
1536
- * case the corresponding {@link LocaleSummary.notices} entry carries the secret-free failure code
1537
- * and message; or the call succeeded but the key was still missing or duplicated in the response
1538
- * after the shared LLM layer's bounded reconcile repair round, in which case no notice is added.
1539
- * Empty in a dry-run.
1540
- */
2104
+ /** Keys the provider failed to translate, for instance because their sub-batch errored. */
1541
2105
  readonly providerFailures: readonly string[];
1542
- /**
1543
- * Plural-category keys verbatra synthesized this run (for example a Polish `items_few` the source
1544
- * never supplied), kept distinct from {@link translated}. Empty unless plural generation was enabled
1545
- * and acted, and empty in a dry-run.
1546
- */
2106
+ /** Keys whose plural categories were generated for the target language rather than translated one by one. */
1547
2107
  readonly generated: readonly string[];
1548
- /**
1549
- * Candidate keys never sent to the provider because a configured `maxTokens` budget already tripped
1550
- * in `"stop"` mode: keys remaining in the current locale once the ceiling was crossed, and every
1551
- * candidate key of a later, fully-skipped locale. Always present, empty by default, same convention as
1552
- * {@link LocaleSummary.integrityMismatches} and {@link LocaleSummary.providerFailures}. Each key keeps
1553
- * its prior lock hash and is picked up again next run, exactly like a `providerFailures` key.
1554
- */
2108
+ /** Keys not translated because the token budget was exhausted under `stop` behavior. */
1555
2109
  readonly budgetWithheld: readonly string[];
1556
- /**
1557
- * Summed token usage across every provider call for this locale (main translation plus plural
1558
- * generation). Absent when no call in this locale reported usage: a dry-run, a token-less provider
1559
- * (DeepL), or a locale with nothing to translate. Never a fabricated `{ inputTokens: 0, outputTokens: 0 }`.
1560
- */
2110
+ /** Token usage for this locale. Absent when the provider does not report usage. */
1561
2111
  readonly usage?: UsageSummary;
1562
- /**
1563
- * Notices for this locale: provider notices (e.g. DeepL graceful-degradation) and SDK notices
1564
- * (e.g. a target language needing more CLDR plural categories than the source supplies). Empty when
1565
- * nothing was degraded or flagged.
1566
- */
2112
+ /** Provider and SDK notices raised while running this locale. Always present, possibly empty. */
1567
2113
  readonly notices: readonly LocaleNotice[];
1568
- /**
1569
- * Keys accepted and written this run that the review heuristics flagged for a second look, sorted
1570
- * by key. Distinct from the placeholder/ICU integrity gate: a review flag is advisory only and never
1571
- * withholds a key, so this never double-reports a key already surfaced through
1572
- * {@link LocaleSummary.integrityMismatches} or {@link LocaleSummary.providerFailures}. Empty when
1573
- * nothing was flagged, always empty for a workbook import (which never calls a provider or recomputes
1574
- * flags on its own path).
1575
- */
2114
+ /** Translated keys flagged as worth a human look. The translations were still written. */
1576
2115
  readonly needsReview: readonly NeedsReviewEntry[];
1577
- /**
1578
- * Keys the translator left blank that still need a translation, drifted or not, sorted by key.
1579
- * Pending work made visible: the row is skipped (nothing is written and the prior lock baseline is
1580
- * kept), but the key is surfaced so an unfinished import is never silently reported as done. Only a
1581
- * workbook import populates this; the provider path, which never leaves a candidate key unfilled by
1582
- * a person, always leaves it empty.
1583
- *
1584
- * Membership is decided by the import-time diff, not by the `status` the row was exported with: a
1585
- * never-translated key exports as `new`, and a first handoff is all-new, which is the most common
1586
- * unfilled case there is. A row exported as `changed` whose key no longer needs work by import time
1587
- * is correspondingly excluded.
1588
- */
2116
+ /** Keys left with no translation after the run, whatever the cause. */
1589
2117
  readonly unfilled: readonly string[];
1590
- /**
1591
- * Rows a workbook import could not read for this locale, each by worksheet row number and offending
1592
- * column, in row order. The sheet's other rows still import; a malformed row is reported, not
1593
- * written. Only a workbook import populates this; the provider path always leaves it empty.
1594
- */
2118
+ /** Unreadable rows from an imported handoff. Always empty for a {@link translate} run. */
1595
2119
  readonly malformedRows: readonly MalformedRowReport[];
1596
- /**
1597
- * Duplicate-key conflicts a workbook import found in this locale's sheet, in row order. The rule is
1598
- * first occurrence wins: the first row for a key is judged and its later occurrences are reported
1599
- * here and otherwise ignored. Only a workbook import populates this; the provider path always leaves
1600
- * it empty.
1601
- */
2120
+ /** Repeated keys from an imported handoff. Always empty for a {@link translate} run. */
1602
2121
  readonly duplicateKeys: readonly DuplicateKeyReport[];
1603
2122
  /**
1604
- * A structured, secret-free error for a locale that threw (an adapter/lock/provider-construction
1605
- * failure isolated as data): present only on a "failed" locale, and only that throw path sets it.
1606
- * A locale that is "failed" because every candidate key was withheld (all under `providerFailures`,
1607
- * `integrityMismatches`, or `budgetWithheld`) carries those lists and any notices instead, and has
1608
- * no `error`. `code` is a preserved string (the underlying provider/adapter error's `code`, or
1609
- * `"LOCALE_FAILED"` as a fallback), intentionally wider than {@link SdkErrorCode}, so do not treat
1610
- * it as a closed set.
2123
+ * Why this locale failed, when the failure came from a thrown error. The `code` is the failure's
2124
+ * own code where it has one, and `LOCALE_FAILED` otherwise. Never carries a secret.
2125
+ *
2126
+ * Absent unless `status` is `failed`, but a `failed` locale does not always carry it: a locale
2127
+ * whose every key was withheld by the integrity gate, a provider failure, or the token budget is
2128
+ * `failed` with nothing thrown, so this stays undefined and the withheld keys are the account of
2129
+ * what went wrong. Treat it as an optional detail on a failure, never as the failure test.
1611
2130
  */
1612
2131
  readonly error?: {
2132
+ /** The failure's own code where it had one, and `LOCALE_FAILED` otherwise. */
1613
2133
  readonly code: string;
2134
+ /** A human-readable description of the failure. Never contains a secret. */
1614
2135
  readonly message: string;
1615
2136
  };
1616
2137
  }
1617
- /** The aggregate result of a run across all target locales. */
2138
+ /**
2139
+ * The result of a whole run, returned by {@link translate}, {@link watch}, and
2140
+ * {@link importWorkbook}.
2141
+ *
2142
+ * Per-locale outcomes are data, not exceptions: inspect `failed` and `partial` to decide an exit
2143
+ * code rather than relying on the call to throw. Only whole-run failures, such as an invalid config
2144
+ * or an unreadable source file, throw an {@link SdkError}.
2145
+ */
1618
2146
  interface RunSummary {
1619
- /** Whether this was a dry-run (no provider calls, no writes). */
2147
+ /** True when the run computed everything but wrote nothing and called no provider. */
1620
2148
  readonly dryRun: boolean;
1621
- /** One {@link LocaleSummary} per target locale, in config order. */
2149
+ /**
2150
+ * The full per-locale account. A {@link translate} or {@link watch} run reports them in
2151
+ * configured target order, whatever order a `locales` subset was given in and whatever order
2152
+ * concurrent locales finished in. An {@link importWorkbook} run does not: it reports them in the
2153
+ * order the handoff yielded them, then appends the configured locales the handoff had nothing
2154
+ * for. Match an entry on its `locale` rather than on its position.
2155
+ */
1622
2156
  readonly locales: readonly LocaleSummary[];
1623
- /** Locales whose run succeeded with nothing withheld (status `"succeeded"`). */
2157
+ /** Names of the locales whose status is `succeeded`. */
1624
2158
  readonly succeeded: readonly string[];
1625
2159
  /**
1626
- * Locales that wrote at least one translation but withheld at least one candidate key (status
1627
- * `"partial"`). A partial locale still exits the CLI `0`: it made progress, and its withheld keys
1628
- * retry next run. Never overlaps `succeeded` or `failed`.
2160
+ * Names of the locales whose status is `partial`: written to disk, but with keys still missing.
2161
+ * Check this alongside `failed` before treating a run as clean. The CLI exits `1` on a partial
2162
+ * locale for that reason.
1629
2163
  */
1630
2164
  readonly partial: readonly string[];
1631
- /** Locales whose run failed, having accepted nothing (status `"failed"`; see each locale's `error`). */
2165
+ /** Names of the locales whose status is `failed`. Check this before treating a run as clean. */
1632
2166
  readonly failed: readonly string[];
1633
- /**
1634
- * Summed token usage across every locale's {@link LocaleSummary.usage}. Absent when no locale in the
1635
- * run reported usage (a dry-run, or an all-token-less-provider run).
1636
- */
2167
+ /** Token usage summed across every locale. Absent when the provider does not report usage. */
1637
2168
  readonly usage?: UsageSummary;
1638
- /** The run-wide token-budget outcome; present only when `maxTokens` is configured. */
2169
+ /** The token budget in force, present only when the config set one. */
1639
2170
  readonly budget?: RunBudget;
1640
2171
  }
1641
2172
 
1642
2173
  /**
1643
- * One target locale's snapshot inside the persisted run-status file. `status` is carried over from
1644
- * `LocaleSummary.status` unchanged, so an empty `needsReview` on a failed or partial locale is never
1645
- * misread as "this locale ran clean with nothing to review".
2174
+ * One locale's entry in a persisted {@link RunStatusFile}. It is a deliberately narrow projection
2175
+ * of {@link LocaleSummary}: only the parts a later session still needs, namely the outcome, the
2176
+ * review flags, and the cost.
1646
2177
  */
1647
2178
  interface RunStatusLocale {
2179
+ /** The target locale this entry describes. */
1648
2180
  readonly locale: string;
2181
+ /** How the locale finished in the recorded run. */
1649
2182
  readonly status: "succeeded" | "partial" | "failed";
1650
- /** The full, unmodified list from `LocaleSummary.needsReview`; already bounded and content-free. */
2183
+ /** Keys the run flagged as worth a human look. */
1651
2184
  readonly needsReview: readonly NeedsReviewEntry[];
1652
- /** Absent exactly when the source `LocaleSummary.usage` was absent; never a fabricated zero. */
2185
+ /** Token usage for this locale. Absent when the provider does not report usage. */
1653
2186
  readonly usage?: UsageSummary;
1654
2187
  }
1655
2188
  /**
1656
- * The persisted shape of `.verbatra-local/run-status.json`: one whole-run snapshot, overwritten on
1657
- * every non-dry-run `translate()`/`watch()` run that reaches the end of its per-locale loop. Contains
1658
- * only the review-flag and token/usage data already computed for `RunSummary`; never a translation
1659
- * string, a provider error message, or a provider notice message.
2189
+ * The snapshot a completed non-dry-run writes to `.verbatra-local/run-status.json`, read back by
2190
+ * {@link runStatus}. It lets a tool started after the run, such as a dashboard opened once
2191
+ * translation finished, still show what was flagged and what it cost.
2192
+ *
2193
+ * It is local state, not project state: unlike the lock-file it is not meant to be committed, and
2194
+ * losing it costs nothing beyond that history.
1660
2195
  */
1661
2196
  interface RunStatusFile {
2197
+ /** The status-file schema version. A file at an unrecognized version is ignored rather than trusted. */
1662
2198
  readonly version: number;
1663
- /** ISO timestamp of when this snapshot was written. */
2199
+ /** When the run finished, as an ISO 8601 timestamp. */
1664
2200
  readonly generatedAt: string;
1665
- /** Summed run-wide token usage, mirroring `RunSummary.usage`; absent under the same conditions. */
2201
+ /** Token usage summed across every locale. Absent when the provider does not report usage. */
1666
2202
  readonly usage?: UsageSummary;
1667
- /** The run-wide token-budget outcome, mirroring `RunSummary.budget`; present only when configured. */
2203
+ /** The token budget in force during the run, present only when one was configured. */
1668
2204
  readonly budget?: RunBudget;
2205
+ /** Per-locale outcomes from the recorded run. */
1669
2206
  readonly locales: readonly RunStatusLocale[];
1670
2207
  }
1671
2208
 
1672
- /** Input for {@link runStatus}: the persisted file is self-contained, so no `config` is needed. */
2209
+ /** Input for {@link runStatus}. */
1673
2210
  interface RunStatusInput {
1674
- /** Directory the run-status file resolves against; defaults to cwd. */
2211
+ /** Directory holding the `.verbatra-local` status directory. Defaults to the process working directory. */
1675
2212
  readonly cwd?: string;
1676
2213
  }
1677
- /** Composition seam for {@link runStatus}: inject a file system for tests. */
2214
+ /** Injectable dependencies for {@link runStatus}. Every field has a working default. */
1678
2215
  interface RunStatusDeps {
2216
+ /** File-system port. Defaults to the real file system. */
1679
2217
  readonly fs?: SdkFs;
1680
2218
  }
1681
2219
  /**
1682
- * The persisted run-status snapshot, or its absence. `available: false` covers a project that has
1683
- * never run a non-dry-run `translate()`/`watch()`, exactly like every other degrade case
1684
- * {@link runStatus} folds into it: never a distinct error, so a caller does not need to special-case
1685
- * "first run" apart from "corrupt file" apart from "unrecognized version".
2220
+ * The result of {@link runStatus}. Having no readable status file is a normal state, not an error:
2221
+ * it simply means no non-dry-run has completed in this directory yet.
1686
2222
  */
1687
2223
  type RunStatusResult = {
2224
+ /** No usable status file was found, so there is nothing to report. */
1688
2225
  readonly available: false;
1689
2226
  } | ({
2227
+ /** A status file was found and parsed; the {@link RunStatusFile} fields are spread alongside. */
1690
2228
  readonly available: true;
1691
2229
  } & RunStatusFile);
1692
2230
  /**
1693
- * Read the most recent run's persisted review-flag and token/usage snapshot from
1694
- * `.verbatra-local/run-status.json`, without calling a provider, writing any file, or mutating the
1695
- * lock-file. Never throws: a missing file, invalid JSON, a schema mismatch, or an unrecognized
1696
- * `version` all degrade to `{ available: false }`, since this file is best-effort telemetry, not a
1697
- * correctness baseline (contrast {@link lockState}, which can throw `LOCK_FILE_INVALID`).
2231
+ * Reads the review-flag and token-usage snapshot that the last non-dry-run {@link translate} or
2232
+ * {@link watch} left behind in `.verbatra-local/run-status.json`. It writes nothing and calls no
2233
+ * provider.
2234
+ *
2235
+ * This exists so a tool started after a run, such as a dashboard opened once translation finished,
2236
+ * can still show which keys were flagged for review and what the run cost, without re-running
2237
+ * anything.
1698
2238
  *
1699
- * @param input - Only the directory the file resolves against; no config is needed.
1700
- * @param deps - Optional composition seam (file system) for tests.
1701
- * @returns `{ available: false }`, or `{ available: true, ...the persisted fields }`.
2239
+ * The read is deliberately total: a missing, oversized, unparseable, schema-invalid, or
2240
+ * wrong-version file all report `available: false` rather than throwing, and so does an injected
2241
+ * `deps.fs` whose read rejects, because stale or unreachable local status should never break the
2242
+ * tool reading it. This call throws nothing.
2243
+ *
2244
+ * @param input - The optional working directory.
2245
+ * @param deps - Optional file-system override.
2246
+ * @returns The persisted run status, or `available: false` when none is usable.
1702
2247
  */
1703
2248
  declare function runStatus(input?: RunStatusInput, deps?: RunStatusDeps): Promise<RunStatusResult>;
1704
2249
 
1705
- /** Diagnostic details, read leniently from a held lock file, about the process currently holding it. */
2250
+ /**
2251
+ * Whatever could be read about the process currently holding a write lock. Both fields are optional
2252
+ * because a lock file left behind by a killed process may be truncated or unreadable, and reporting
2253
+ * a partial holder is more useful than reporting none.
2254
+ */
1706
2255
  interface LockHolder {
1707
- /** The holder's process id, when the lock file could be read and carried a numeric `pid`. */
2256
+ /** The process ID recorded when the lock was taken. */
1708
2257
  readonly pid?: number;
1709
- /** The holder's acquire time as an ISO-8601 string, when the lock file carried a string `acquiredAt`. */
2258
+ /** When the lock was taken, as an ISO 8601 timestamp. */
1710
2259
  readonly acquiredAt?: string;
1711
2260
  }
1712
- /** One wait-progress notification emitted while an acquire is blocked on a held lock. */
2261
+ /**
2262
+ * Reported while a run waits for another process to release a locale's write lock. It exists so a
2263
+ * CLI or UI can explain a stall rather than appearing to hang, and can name the lock file a user
2264
+ * may need to delete after a crash.
2265
+ */
1713
2266
  interface LockWaitEvent {
1714
- /** The on-disk path of the contended lock file, the exact path a person can delete if it is orphaned. */
2267
+ /** Absolute path of the lock file being waited on. */
1715
2268
  readonly lockPath: string;
1716
- /** Milliseconds elapsed since this acquire started waiting, for a "still waiting" progress line. */
2269
+ /** How long this acquisition has been waiting, in milliseconds. */
1717
2270
  readonly elapsedMs: number;
1718
- /**
1719
- * Diagnostics about the holding process, present only when the lock file could be read and parsed.
1720
- * Purely for messaging; never consulted for any acquire decision.
1721
- */
2271
+ /** What is known about the holding process, when anything could be read. */
1722
2272
  readonly holder?: LockHolder;
1723
2273
  }
1724
- /** Called while an acquire is blocked, to surface wait progress; the lock module itself writes no output. */
2274
+ /**
2275
+ * Called on each poll while waiting for a locale's write lock. Passed as `onLockWait` to
2276
+ * {@link translate} and {@link watch}.
2277
+ */
1725
2278
  type LockWaitListener = (event: LockWaitEvent) => void;
1726
2279
 
1727
2280
  /**
1728
- * One structured progress notification emitted while a run advances through its locales and their
1729
- * provider sub-batches. Emitted to a caller-supplied {@link ProgressListener}; the SDK itself writes
1730
- * nothing to any stream, so a caller (the CLI) is free to render these to stderr and keep stdout a
1731
- * clean summary or NDJSON stream. Discriminated on `type`, mirroring the lock-wait event contract.
2281
+ * Any event a run emits while it works. Discriminate on `type`. Progress is reported rather than
2282
+ * printed so that a CLI, a dashboard, and a CI log can each render it in their own way.
1732
2283
  */
1733
2284
  type ProgressEvent = LocaleStartedEvent | SubBatchProgressEvent | LocaleFinishedEvent | RunFinishedEvent;
1734
- /** A locale is about to be processed: its position in the run's target-locale order. */
2285
+ /** Emitted when a locale's work begins. */
1735
2286
  interface LocaleStartedEvent {
2287
+ /** Discriminant for {@link ProgressEvent}. */
1736
2288
  readonly type: "locale-started";
1737
- /** The target locale about to run. */
2289
+ /** The locale starting. */
1738
2290
  readonly locale: string;
1739
- /** Zero-based index of this locale in the run's target-locale order. */
2291
+ /**
2292
+ * This locale's 0-based position among the run's locales. With concurrency above 1 several
2293
+ * locales are in flight at once, so these indices do not arrive in order.
2294
+ */
1740
2295
  readonly localeIndex: number;
1741
- /** Total number of target locales in this run. */
2296
+ /** How many locales the run covers in total. */
1742
2297
  readonly totalLocales: number;
1743
2298
  }
1744
2299
  /**
1745
- * One sub-batch of a locale's main translation loop has been reached. Emitted once per loop
1746
- * iteration, in order, so a caller can render a "batch N of M" line. Fired at the top of the
1747
- * iteration regardless of whether the batch then makes a provider call: a budget-withheld batch
1748
- * (the run's token budget already tripped) still emits this event, which keeps `batchIndex` 1-based
1749
- * and contiguous against the total. A dry-run never reaches this loop and so never emits it.
2300
+ * Emitted as each sub-batch of a locale is reached, immediately before the provider call for it is
2301
+ * attempted. Large locales are split into batches, so this is the finer-grained signal to drive a
2302
+ * progress bar with.
2303
+ *
2304
+ * It announces an attempt rather than confirming a send: a batch withheld because the token budget
2305
+ * has already stopped the run still emits, and no provider call follows it. That is deliberate, so
2306
+ * that `batchIndex` always advances to `totalBatches` and a progress bar reaches its end instead of
2307
+ * stalling at the point the budget tripped.
1750
2308
  */
1751
2309
  interface SubBatchProgressEvent {
2310
+ /** Discriminant for {@link ProgressEvent}. */
1752
2311
  readonly type: "sub-batch";
1753
- /** The locale whose entries this sub-batch carries. */
2312
+ /** The locale this batch belongs to. */
1754
2313
  readonly locale: string;
1755
- /** One-based index of this sub-batch within the locale. */
2314
+ /** This batch's 1-based position within the locale, counting up to `totalBatches`. */
1756
2315
  readonly batchIndex: number;
1757
- /** Total number of sub-batches this locale's main translation loop runs. */
2316
+ /** How many batches the locale was split into. */
1758
2317
  readonly totalBatches: number;
1759
2318
  }
1760
- /** A locale finished processing: how many keys it accepted (translated). */
2319
+ /** Emitted when a locale's work ends, whether it succeeded, partially succeeded, or failed. */
1761
2320
  interface LocaleFinishedEvent {
2321
+ /** Discriminant for {@link ProgressEvent}. */
1762
2322
  readonly type: "locale-finished";
1763
- /** The target locale that finished. */
2323
+ /** The locale that finished. */
1764
2324
  readonly locale: string;
1765
- /** Number of keys accepted (translated) for this locale. In a dry-run, the keys that would be translated. */
2325
+ /** How many keys were newly translated for this locale. */
1766
2326
  readonly translated: number;
1767
- /**
1768
- * Zero-based index of this locale in the run's target-locale order, carrying the same value as the
1769
- * matching {@link LocaleStartedEvent}. This is a correlation key, so a consumer can pair a finish
1770
- * with its start without matching on the locale name.
1771
- *
1772
- * It is deliberately NOT a progress counter. The worker pool claims indices up front, so this is a
1773
- * claim ordinal rather than a completion ordinal: under concurrency the locales finish in a
1774
- * different order, and rendering `localeIndex + 1` of `totalLocales` on a finish produces a
1775
- * counter that jumps around and never reaches its own total in order. Do not reintroduce one.
1776
- */
2327
+ /** This locale's 0-based position among the run's locales. */
1777
2328
  readonly localeIndex: number;
1778
- /** Total number of target locales in this run, mirroring {@link LocaleStartedEvent}. */
2329
+ /** How many locales the run covers in total. */
1779
2330
  readonly totalLocales: number;
1780
2331
  }
1781
- /** The run reached the end of its locale loop: how many locales it processed. */
2332
+ /** Emitted once, after every locale has finished. */
1782
2333
  interface RunFinishedEvent {
2334
+ /** Discriminant for {@link ProgressEvent}. */
1783
2335
  readonly type: "run-finished";
1784
- /**
1785
- * Number of locales the run processed (including failed locales: a per-locale failure is isolated
1786
- * as a summary rather than thrown, so it is still counted here).
1787
- */
2336
+ /** How many locales completed, including those that failed. */
1788
2337
  readonly localesCompleted: number;
1789
2338
  }
1790
2339
  /**
1791
- * Called with each {@link ProgressEvent} as a run advances; the SDK emits no output of its own. A
1792
- * caller (the CLI) uses this to render progress, always to stderr, so stdout stays byte-identical to
1793
- * a run with no listener attached.
2340
+ * Called for each {@link ProgressEvent} a run emits. Passed as `onProgress` to {@link translate}
2341
+ * and {@link watch}.
1794
2342
  */
1795
2343
  type ProgressListener = (event: ProgressEvent) => void;
1796
2344
 
1797
- /** Everything the one-shot run needs: the validated config and where/how to run it. */
2345
+ /** Input for {@link translate}. Only `config` is required; every other field has a default. */
1798
2346
  interface TranslateInput {
1799
- /** The validated configuration (typically from {@link loadConfig}). */
2347
+ /** The resolved project config, normally from {@link loadConfig}. */
1800
2348
  readonly config: VerbatraConfig;
1801
- /** Directory the file pattern and lock-file resolve against; defaults to the current working directory. */
2349
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
1802
2350
  readonly cwd?: string;
1803
- /** When true, read + diff + report only: the provider is never constructed or called and nothing is written. */
2351
+ /**
2352
+ * Restrict the run to a subset of the configured target locales, which is how you translate one
2353
+ * locale at a time against a rate-limited provider. Defaults to every configured target. The
2354
+ * result keeps the configured order whatever order the subset is given in, and any locale named
2355
+ * here that is not a configured target fails the whole run with `UNKNOWN_LOCALE` before anything
2356
+ * is read or spent. An explicit empty array selects no locale at all rather than every locale.
2357
+ *
2358
+ * Locales left out are untouched: their files, their lock entries, and their translation-memory
2359
+ * entries all stay as they were. The run-status file always describes the most recent run alone,
2360
+ * so a subset run narrows it to that subset.
2361
+ */
2362
+ readonly locales?: readonly string[];
2363
+ /**
2364
+ * Compute the whole run but write nothing and call no provider. The returned
2365
+ * {@link RunSummary} reports what would have happened, which makes this safe to run in CI to
2366
+ * preview work without spending anything. Defaults to false.
2367
+ */
1804
2368
  readonly dryRun?: boolean;
1805
2369
  /**
1806
- * When true, remove orphaned keys (target keys absent from source) from the written file and the lock.
1807
- * Off by default. When set, takes precedence over the config's `prune` option for this run; when unset,
1808
- * the config's `prune` applies. Only `diff.orphaned` keys are ever removed; no other key is touched.
2370
+ * Remove keys that no longer exist in the source. Defaults to the config's `prune`, then to
2371
+ * false, so orphaned keys are reported but kept unless removal is asked for explicitly.
1809
2372
  */
1810
2373
  readonly prune?: boolean;
1811
2374
  /**
1812
- * When true, synthesize the CLDR plural forms a richer target language requires but the source lacks
1813
- * (i18next-JSON + LLM providers only; every other case falls back to the existing warning). Off by
1814
- * default. When set, takes precedence over the config's `generatePlurals` option for this run; when
1815
- * unset, the config's `generatePlurals` applies.
2375
+ * Generate the plural categories a target language requires rather than translating each
2376
+ * category separately. Defaults to the config's `generatePlurals`, then to false.
1816
2377
  */
1817
2378
  readonly generatePlurals?: boolean;
1818
2379
  /**
1819
- * Called while a locale's write lock is blocked on another process holding it: once after the first
1820
- * failed acquire (with the holder's pid/acquiredAt when the lock file is readable), then periodically
1821
- * with the growing elapsed time. Never called for an uncontended run. The SDK writes no output; this
1822
- * is the only wait-progress signal (the CLI renders its "still waiting" line from it).
2380
+ * Called while waiting on another process's write lock, so a CLI can explain a stall instead of
2381
+ * appearing to hang.
1823
2382
  */
1824
2383
  readonly onLockWait?: LockWaitListener;
1825
- /**
1826
- * Called as the run advances: once per locale before it starts and once after it finishes, once
1827
- * per provider sub-batch within a locale, and once when the whole locale loop ends. Never fires a
1828
- * sub-batch event on a dry-run (no provider call is made). The SDK writes no output; this is the
1829
- * only progress signal (the CLI renders it to stderr, keeping stdout byte-identical).
1830
- *
1831
- * Pairing is not guaranteed when the run throws: a whole-run failure (for example a corrupt
1832
- * lock-file surfacing as `LOCK_FILE_INVALID`, which re-throws instead of being isolated per locale)
1833
- * can emit a `locale-started` event with no matching `locale-finished`, and no `run-finished` at
1834
- * all. A per-locale failure that is isolated (not re-thrown) still emits both, and is counted in
1835
- * `run-finished`.
1836
- */
2384
+ /** Called as locales and sub-batches start and finish, for progress reporting. */
1837
2385
  readonly onProgress?: ProgressListener;
1838
- /**
1839
- * Override how long a locale's write lock keeps retrying before failing with `LOCK_CONTENDED`, in
1840
- * milliseconds. Defaults to the lock's own 10-minute default when unset (surfaced to the CLI as
1841
- * `--lock-timeout`).
1842
- */
2386
+ /** How long to wait for a locale's write lock before failing with `LOCK_CONTENDED`. */
1843
2387
  readonly lockAcquireTimeoutMs?: number;
1844
2388
  /**
1845
- * How many target locales may run at once, a positive integer (surfaced to the CLI as
1846
- * `--concurrency`). Defaults to 1, which runs locales strictly in sequence and is byte-identical
1847
- * to a run with this option unset. A value below 1 or a non-integer is rejected with a whole-run
1848
- * `CONCURRENCY_INVALID` error. On a live run, a value greater than 1 is rejected with a whole-run
1849
- * `CONCURRENCY_BUDGET_CONFLICT` error when the config sets a `maxTokens` budget, because
1850
- * concurrency would make the budget's stop guarantee nondeterministic; a dry run is exempt. The
1851
- * per-locale write locks already isolate concurrent locales on disk, so no extra locking is added.
2389
+ * How many locales to run at once. Must be an integer of at least 1; defaults to 1. On a live
2390
+ * run it cannot be combined with a configured token budget, because concurrent locales would
2391
+ * overshoot the budget nondeterministically.
1852
2392
  */
1853
2393
  readonly concurrency?: number;
1854
2394
  /**
1855
- * Whether to use the local content-addressed translation-memory cache (`verbatra.cache.json`). On by
1856
- * default; the CLI exposes it as `--no-cache`. When false, both the cache read and the end-of-run
1857
- * cache write are skipped: the run makes exactly the provider calls it would with no cache present,
1858
- * and leaves any existing cache file untouched. Ignored on a dry-run, which never reads or writes the
1859
- * cache regardless. See the cache module for the fingerprint and degrade-to-empty contract.
2395
+ * Consult and update the translation memory. Defaults to true. Turning it off forces every key
2396
+ * through the provider, which is what to do when you want to re-pay for a fresh translation.
1860
2397
  */
1861
2398
  readonly cache?: boolean;
1862
2399
  }
1863
- /** Composition seam: inject a registry, a provider builder, and a file system for tests. */
2400
+ /** Injectable dependencies for {@link translate}. Every field has a working default. */
1864
2401
  interface TranslateDeps {
1865
- /** Adapter registry to resolve the format from; defaults to the built-in registry. */
2402
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1866
2403
  readonly adapterRegistry?: AdapterRegistry;
1867
- /** Provider builder; defaults to constructing the configured provider (which reads its key from env). */
2404
+ /** Provider factory. Defaults to constructing the provider named in the config. */
1868
2405
  readonly createProvider?: CreateProvider;
1869
- /** File system for existence checks and the lock-file; defaults to the real file system. */
2406
+ /** File-system port. Defaults to the real file system. */
1870
2407
  readonly fs?: SdkFs;
1871
2408
  }
1872
2409
  /**
1873
- * The one-shot end-to-end translate flow. Whole-run failures (config already validated
1874
- * by the caller, unknown format, provider construction, unreadable/invalid source,
1875
- * corrupt lock-file) throw a structured SdkError. Per-locale failures are isolated: a
1876
- * failing locale is reported and the run continues; the lock-file reflects exactly the
1877
- * locales that succeeded. Dry-run reads + diffs + reports without constructing/calling
1878
- * the provider and without writing any file or the lock-file.
1879
- *
1880
- * A per-locale failure does not throw: it is recorded on that locale's {@link LocaleSummary} as
1881
- * `status: "failed"` with a secret-free `{ code, message }`, where `code` is a preserved string (the
1882
- * underlying provider/adapter code, or `"LOCALE_FAILED"` as a fallback), not necessarily an
1883
- * {@link SdkErrorCode}. DeepL notices, integrity mismatches, and invalid-ICU source keys likewise
1884
- * surface on each `LocaleSummary`, never as throws.
1885
- *
1886
- * A dry-run reads the lock once, outside any lock, and reuses that one snapshot for every locale:
1887
- * it never writes, so there is nothing to serialize against. A live run instead re-reads the lock
1888
- * fresh inside each locale's own write lock (see `runLiveLocale`), so two concurrent live calls
1889
- * never both diff against the same stale pre-loop snapshot. The token-budget tracker is fresh per
1890
- * invocation: the `maxTokens` ceiling is per-run, not cumulative across watch cycles.
1891
- *
1892
- * On a non-dry-run that reaches the end of the loop, the run's review-flag and token/usage data is
1893
- * also written to `.verbatra-local/run-status.json` (readable back through {@link runStatus}). This
1894
- * write is best-effort: any failure is caught and swallowed, never re-thrown and never reflected on
1895
- * the returned {@link RunSummary}.
1896
- *
1897
- * @param input - The validated config and run options (cwd, dryRun, prune, generatePlurals).
1898
- * @param deps - Optional composition seams (registry, provider builder, file system) for tests.
1899
- * @returns A {@link RunSummary}: the per-locale {@link LocaleSummary}s and the succeeded/partial/failed locale lists.
2410
+ * Runs the one-shot translation flow over every configured target locale, or over the subset named
2411
+ * by `locales`: read the source, diff
2412
+ * each locale against the lock-file baseline, translate what is missing or stale, verify placeholder
2413
+ * and ICU integrity, write the locale files, and update the lock-file and translation memory.
2414
+ *
2415
+ * Failure handling is the contract worth understanding. Whole-run problems, such as an unreadable
2416
+ * source file or a provider that cannot be constructed, throw an {@link SdkError} before any locale
2417
+ * runs. Once locales are running, a per-locale failure is recorded on that locale's
2418
+ * {@link LocaleSummary} and the other locales continue, so one unreachable provider or one
2419
+ * unwritable file never discards work that succeeded. A locale whose write lock stays contended is
2420
+ * one of these per-locale failures: it is recorded with code `LOCK_CONTENDED` on that locale's
2421
+ * summary rather than thrown. Callers should therefore inspect {@link RunSummary.failed} and
2422
+ * {@link RunSummary.partial} rather than relying on a thrown error to detect trouble. A partial
2423
+ * locale is a locale whose file was written with some keys missing, so treating it as clean would
2424
+ * ship an incomplete translation; the CLI exits `1` for it exactly as it does for a failed one.
2425
+ *
2426
+ * A corrupt lock-file is the one exception. On a live run each locale reads the lock-file inside
2427
+ * its own write lock, so `LOCK_FILE_INVALID` escapes as a thrown error even after earlier locales
2428
+ * have already translated and written. No further locale is started, and the locales in flight
2429
+ * finish and release their write locks before the call rejects.
2430
+ *
2431
+ * Each locale takes its own write lock for the read-modify-write, so concurrent runs and
2432
+ * single-key edits cannot interleave on one file. Translations that fail the integrity gate are
2433
+ * refused rather than written, leaving the previous value intact.
2434
+ *
2435
+ * Set `dryRun` to compute the whole plan without writing or spending anything.
2436
+ *
2437
+ * @param input - The config and the per-run options.
2438
+ * @param deps - Optional adapter registry, provider factory, and file-system overrides.
2439
+ * @returns The per-locale account of the run, including usage and budget.
2440
+ *
2441
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: `locales` names a locale that is not a configured
2442
+ * target. Thrown before anything is read, written, or spent.
1900
2443
  * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1901
- * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider factory threw (this wraps the
1902
- * provider's own error, including a missing `*_API_KEY` reported as `MISSING_API_KEY`); only on a
1903
- * non-dry-run, since dry-run never constructs the provider.
2444
+ * @throws {@link SdkError} `CONCURRENCY_INVALID`: `concurrency` is not an integer of at least 1.
2445
+ * @throws {@link SdkError} `CONCURRENCY_BUDGET_CONFLICT`: a live run combined a `concurrency` above
2446
+ * 1 with a configured token budget. A dry run is exempt.
2447
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
2448
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
2449
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1904
2450
  * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1905
- * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be read or parsed (wraps the
1906
- * adapter read error).
1907
- * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is present but corrupt or oversized.
1908
- * @throws {@link SdkError} `CONCURRENCY_INVALID`: `concurrency` is defined but not an integer of at
1909
- * least 1; raised before any locale runs.
1910
- * @throws {@link SdkError} `CONCURRENCY_BUDGET_CONFLICT`: a live run set `concurrency` greater than 1
1911
- * while the config configures a `maxTokens` budget; raised before any provider call (a dry run is
1912
- * exempt).
2451
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
2452
+ * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider could not be constructed,
2453
+ * most often because its API key environment variable is unset. Not thrown on a dry run, which
2454
+ * never constructs a provider.
2455
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
2456
+ * unsupported version. A dry run reads it once before any locale runs; a live run reads it per
2457
+ * locale, so this can abort the run after other locales have already been written.
2458
+ *
1913
2459
  * @example
1914
2460
  * ```ts
1915
2461
  * import { loadConfig, translate } from "@verbatra/sdk";
1916
2462
  *
1917
- * // The provider reads its API key from the environment (e.g. ANTHROPIC_API_KEY); no key is passed here.
1918
2463
  * const config = await loadConfig();
1919
- * const summary = await translate({ config });
2464
+ * const summary = await translate({
2465
+ * config,
2466
+ * onProgress: (event) => {
2467
+ * if (event.type === "locale-finished") {
2468
+ * console.log(`${event.locale}: ${event.translated} keys`);
2469
+ * }
2470
+ * },
2471
+ * });
1920
2472
  *
1921
- * for (const locale of summary.locales) {
1922
- * if (locale.status === "failed") {
1923
- * // Surfaced, not thrown: code is a preserved string (LOCALE_FAILED is only the fallback).
1924
- * console.error(`${locale.locale}: ${locale.error?.code} ${locale.error?.message}`);
1925
- * } else {
1926
- * console.log(`${locale.locale}: ${locale.translated.length} translated, ${locale.notices.length} notices`);
2473
+ * if (summary.failed.length > 0 || summary.partial.length > 0) {
2474
+ * for (const locale of summary.locales.filter((entry) => entry.status !== "succeeded")) {
2475
+ * console.error(`${locale.locale}: ${locale.status} ${locale.error?.message ?? ""}`);
1927
2476
  * }
2477
+ * process.exitCode = 1;
1928
2478
  * }
1929
- *
1930
- * // Preview only: no provider call, no writes.
1931
- * const preview = await translate({ config, dryRun: true });
1932
2479
  * ```
1933
2480
  */
1934
2481
  declare function translate(input: TranslateInput, deps?: TranslateDeps): Promise<RunSummary>;
1935
2482
 
1936
2483
  /**
1937
2484
  * The two delimiter-separated interchange formats. They are one implementation with one parameter, the
1938
- * field delimiter: `tsv` is not a second format, it is `csv` with a tab.
2485
+ * field delimiter: `tsv` is not a second format, it is `csv` with a tab. The member is also the file
2486
+ * extension an export writes and an import reads back.
1939
2487
  */
1940
2488
  type DelimitedFormat = "csv" | "tsv";
1941
2489
 
1942
2490
  /**
1943
- * The interchange format a handoff moves in. `xlsx` is the default and the only shape that carries
1944
- * every target locale in one artifact; `csv` and `tsv` are plain text and move one file per target
1945
- * locale, so their path names a directory rather than a file.
2491
+ * The file shape of a translator handoff: a single styled `.xlsx` workbook with one sheet per
2492
+ * locale, or one plain `.csv` or `.tsv` file per locale.
2493
+ *
2494
+ * The workbook is the friendlier artifact to hand a human translator, while the delimited forms are
2495
+ * easier to diff, review, and feed to another tool. {@link exportWorkbook} and
2496
+ * {@link importWorkbook} both accept either.
1946
2497
  */
1947
2498
  type ExchangeFormat = "xlsx" | DelimitedFormat;
2499
+ /**
2500
+ * Every {@link ExchangeFormat} at runtime, for a tool that has to validate a `--format` argument or
2501
+ * offer the choices to a user. The order is the order to present them in: the workbook first, then
2502
+ * the delimited forms.
2503
+ *
2504
+ * It is derived from a record keyed by {@link ExchangeFormat} itself, so a format added to the type
2505
+ * without being added here fails to compile. A consumer that checks membership against this list can
2506
+ * therefore never silently reject a format the SDK accepts.
2507
+ */
2508
+ declare const EXCHANGE_FORMATS: readonly ExchangeFormat[];
2509
+ /** The {@link ExchangeFormat} {@link exportWorkbook} and {@link importWorkbook} use when the caller names none. */
2510
+ declare const DEFAULT_EXCHANGE_FORMAT: ExchangeFormat;
1948
2511
 
1949
- /** Default workbook output path, relative to the resolved working directory. */
2512
+ /** Default output path for an `.xlsx` handoff, used when {@link ExportWorkbookInput.out} is omitted. */
1950
2513
  declare const DEFAULT_WORKBOOK_PATH = "verbatra-translations.xlsx";
1951
2514
  /**
1952
- * Default delimited output directory, relative to the resolved working directory. A delimited export
1953
- * writes one file per target locale, so its output path names a directory, not a file.
2515
+ * Default output directory for a delimited handoff. It carries no extension because it names a
2516
+ * directory, not a file: the export creates it and writes one `<locale>.<format>` file inside it
2517
+ * per exported locale, such as `de.csv`.
1954
2518
  */
1955
2519
  declare const DEFAULT_DELIMITED_PATH = "verbatra-translations";
1956
- /** Input for {@link exportWorkbook}: the validated config and where/how to run the export. */
2520
+ /** Input for {@link exportWorkbook}. */
1957
2521
  interface ExportWorkbookInput {
1958
- /** The validated configuration (typically from {@link loadConfig}). */
2522
+ /** The resolved project config, normally from {@link loadConfig}. */
1959
2523
  readonly config: VerbatraConfig;
1960
- /** Directory the file pattern, lock-file, and output path resolve against; defaults to cwd. */
2524
+ /** Directory the `files.pattern` and `out` are resolved against. Defaults to the process working directory. */
1961
2525
  readonly cwd?: string;
1962
2526
  /**
1963
- * Output path; defaults to {@link DEFAULT_WORKBOOK_PATH} for `xlsx` and to
1964
- * {@link DEFAULT_DELIMITED_PATH} for `csv` and `tsv`. It is the workbook file for `xlsx` and the
1965
- * directory the per-locale files are written into for `csv` and `tsv` (created if missing).
2527
+ * Where to write the handoff. Defaults to {@link DEFAULT_WORKBOOK_PATH} for `xlsx` and to
2528
+ * {@link DEFAULT_DELIMITED_PATH} for the delimited formats.
1966
2529
  */
1967
2530
  readonly out?: string;
1968
- /** Subset of target locales to export; defaults to all configured target locales. */
2531
+ /** Restrict the export to these target locales. Defaults to every configured target locale. */
1969
2532
  readonly locales?: readonly string[];
1970
- /** Include unchanged keys (off by default; export is missing-and-changed only). */
2533
+ /**
2534
+ * Include keys that are already up to date, not just the missing and stale ones. Useful when a
2535
+ * translator needs the surrounding context to translate consistently. Defaults to false.
2536
+ */
1971
2537
  readonly includeUnchanged?: boolean;
1972
- /** Interchange format to write; defaults to `xlsx`, so an existing caller is unaffected. */
2538
+ /** The handoff shape to write. Defaults to `xlsx`. */
1973
2539
  readonly format?: ExchangeFormat;
1974
2540
  }
1975
- /** Composition seam for {@link exportWorkbook}: inject a registry and a file system for tests. */
2541
+ /** Injectable dependencies for {@link exportWorkbook}. Every field has a working default. */
1976
2542
  interface ExportWorkbookDeps {
2543
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1977
2544
  readonly adapterRegistry?: AdapterRegistry;
2545
+ /** File-system port. Defaults to the real file system. */
1978
2546
  readonly fs?: SdkFs;
1979
2547
  }
1980
- /** The outcome of an export: where it was written and how many rows per locale. */
2548
+ /** What {@link exportWorkbook} wrote. */
1981
2549
  interface ExportWorkbookResult {
1982
- /**
1983
- * The absolute path written to: the workbook file for `xlsx`, the directory holding one
1984
- * `<locale>.csv` or `<locale>.tsv` per exported locale for the delimited formats.
1985
- */
2550
+ /** The absolute path written, or the shared base path when one file per locale was written. */
1986
2551
  readonly path: string;
1987
- /** Per-locale row counts, in config order; the same set the workbook carries. */
2552
+ /** Row counts per exported locale. */
1988
2553
  readonly locales: readonly {
2554
+ /** The exported target locale. */
1989
2555
  readonly locale: string;
2556
+ /** How many translatable rows that locale contributed. */
1990
2557
  readonly rows: number;
1991
2558
  }[];
1992
2559
  }
1993
2560
  /**
1994
- * Export the strings needing human translation into a styled `.xlsx` workbook, or into one plain
1995
- * `.csv` or `.tsv` file per target locale. Each target locale is diffed against the source and lock
1996
- * baseline to pick the rows (missing and changed by default; add unchanged with `includeUnchanged`),
1997
- * and the result is written to `out`. No provider is called and no lock-file is written.
2561
+ * Writes the strings awaiting translation to a handoff a human translator can work in: a styled
2562
+ * `.xlsx` workbook with one sheet per locale, or one `.csv` or `.tsv` file per locale.
2563
+ *
2564
+ * By default only missing and stale keys are exported, which is what makes the handoff a work list
2565
+ * rather than a dump of the whole project. Each row carries the source text alongside any existing
2566
+ * translation and a review status, so the translator sees what changed and why a string was
2567
+ * flagged.
2568
+ *
2569
+ * This is the outbound half of the exchange; {@link importWorkbook} reads the filled handoff back
2570
+ * through the same diff, lock, and integrity checks. It writes only the handoff file and never
2571
+ * touches the locale files or the lock-file.
1998
2572
  *
1999
- * The delimited formats trade the workbook's protection for a diffable, git-friendly handoff: a
2000
- * workbook leaves only the Translation column editable and hides the source hash, while every field of
2001
- * a delimited file is editable and its source hash is visible. An edited source hash is never trusted
2002
- * on import; it is compared against the live source and the row is withheld as drift.
2573
+ * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
2574
+ * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
2575
+ * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
2576
+ * error from a target file.
2003
2577
  *
2004
- * A delimited export also writes a small hidden manifest naming the locales it wrote. It exists so a
2005
- * re-export with a narrower `locales` selection retires the locales it dropped: their files stay on
2006
- * disk, but the next import recognizes them as leftovers from the earlier run and refuses to apply
2007
- * them. The export deletes nothing, so an unrelated file in the output directory is never at risk.
2578
+ * Writing the handoff itself is unwrapped in the same way. It is not a locale file, so a failure to
2579
+ * create the output directory or to write the file does not become `TARGET_UNWRITABLE`; the
2580
+ * underlying file-system error propagates as it is.
2008
2581
  *
2009
- * @param input - The validated config and export options.
2010
- * @param deps - Optional composition seams (registry, file system) for tests.
2011
- * @returns Where the export was written and the per-locale row counts.
2012
- * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
2013
- * with the same meanings as in `translate`, or `UNKNOWN_LOCALE` when a requested locale is not
2014
- * among the configured target locales.
2582
+ * @param input - The config, output path, locale filter, and handoff format.
2583
+ * @param deps - Optional adapter registry and file-system overrides.
2584
+ * @returns The path written and the per-locale row counts.
2585
+ *
2586
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
2587
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
2588
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
2589
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
2590
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
2591
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
2592
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
2593
+ * unsupported version.
2594
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
2595
+ * @throws The underlying file-system error, unwrapped, when the handoff could not be written: `out`
2596
+ * names a directory that does not exist or is not writable, or the device is full. Branch on the
2597
+ * Node `code`, such as `ENOENT`, rather than on the message, which can name the internal temporary
2598
+ * file the atomic write uses.
2015
2599
  */
2016
2600
  declare function exportWorkbook(input: ExportWorkbookInput, deps?: ExportWorkbookDeps): Promise<ExportWorkbookResult>;
2017
2601
 
2018
- /** Input for {@link importWorkbook}: the validated config, the workbook path, and run options. */
2602
+ /** Input for {@link importWorkbook}. */
2019
2603
  interface ImportWorkbookInput {
2020
- /** The validated configuration (typically from {@link loadConfig}). */
2604
+ /** The resolved project config, normally from {@link loadConfig}. */
2021
2605
  readonly config: VerbatraConfig;
2022
2606
  /**
2023
- * Path to the filled handoff to import: the workbook file for `xlsx`, and for `csv` and `tsv`
2024
- * either one `<locale>.csv` / `<locale>.tsv` file or a directory holding one per target locale.
2607
+ * Path to the filled handoff. For a delimited import the path is tried as a single file first,
2608
+ * so one individual `<locale>.<format>` file can be imported on its own, with the locale taken
2609
+ * from its file name. If no file exists there, the path is treated as the directory the
2610
+ * per-locale files were written into and every configured target locale found inside is read.
2025
2611
  */
2026
2612
  readonly workbook: string;
2027
- /** Directory the file pattern, lock-file, and workbook path resolve against; defaults to cwd. */
2613
+ /** Directory the `files.pattern` and `workbook` are resolved against. Defaults to the process working directory. */
2028
2614
  readonly cwd?: string;
2029
- /** When true, validate and report only: write no locale file and update no lock-file. */
2615
+ /**
2616
+ * Read and validate the handoff but write nothing. The returned {@link RunSummary} reports what
2617
+ * would have been applied, which is the safe way to inspect a handoff before trusting it.
2618
+ * Defaults to false.
2619
+ */
2030
2620
  readonly dryRun?: boolean;
2031
- /** Interchange format to read; defaults to `xlsx`, so an existing caller is unaffected. */
2621
+ /** The handoff shape to read. Defaults to `xlsx`. */
2032
2622
  readonly format?: ExchangeFormat;
2033
2623
  }
2034
- /** Composition seam for {@link importWorkbook}: inject a registry and a file system for tests. */
2624
+ /** Injectable dependencies for {@link importWorkbook}. Every field has a working default. */
2035
2625
  interface ImportWorkbookDeps {
2626
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
2036
2627
  readonly adapterRegistry?: AdapterRegistry;
2628
+ /** File-system port. Defaults to the real file system. */
2037
2629
  readonly fs?: SdkFs;
2038
2630
  }
2039
2631
  /**
2040
- * Import a filled handoff back into the locale files. Each target-locale data sheet runs the same
2041
- * source-drift, placeholder, and ICU checks as the translate flow, the accepted values are written
2042
- * through the format adapter, and the lock is updated. Returns a {@link RunSummary} structurally
2043
- * identical to `translate`'s.
2632
+ * Reads a filled translator handoff back into the locale files. It is the inbound half of the
2633
+ * exchange that {@link exportWorkbook} starts, and it calls no provider: every value comes from the
2634
+ * handoff.
2635
+ *
2636
+ * Imported values are held to the same integrity gate as provider output, so a translator who drops
2637
+ * a placeholder or breaks ICU syntax has that row refused rather than written. Each locale takes
2638
+ * its write lock, and the lock-file and translation memory are updated exactly as in a
2639
+ * {@link translate} run, so an imported translation counts as up to date afterwards.
2044
2640
  *
2045
- * Whole-run failures (unknown format, unreadable/invalid/oversized workbook, corrupt lock) throw a
2046
- * structured {@link SdkError}. A per-sheet failure (a sheet named for a locale not in config, a
2047
- * broken-round-trip key, a write failure) is isolated as that locale's `status: "failed"`, not a
2048
- * throw; per-row rejections are withheld and reported on the locale. A configured target locale with
2049
- * no sheet at all (a deleted, renamed, or reordered tab) is reconciled after the sheet loop and
2050
- * reported as that locale's `status: "failed"` (`WORKBOOK_SHEET_MISSING`) rather than silently
2051
- * dropped. For a delimited directory, a locale file the most recent export into that directory did not
2052
- * write is a leftover from an earlier, wider selection: it is never applied and is reported as that
2053
- * locale's `status: "failed"` (`HANDOFF_FILE_STALE`). Dry-run validates and reports without writing any
2054
- * locale or lock file, and skips lock acquisition (there is nothing to protect).
2641
+ * Damage is contained rather than fatal: a blank row keeps the existing translation and its
2642
+ * baseline, an unreadable row is reported as a {@link MalformedRowReport}, and a repeated key is
2643
+ * reported as a {@link DuplicateKeyReport} with the first occurrence winning. All three surface on
2644
+ * the returned {@link RunSummary} rather than aborting the import. A sheet or file naming a locale
2645
+ * that is not configured is contained the same way: that locale fails with `CONFIG_INVALID` on its
2646
+ * own {@link LocaleSummary}, so nothing is written to an unmanaged path and the configured locales
2647
+ * still import. Once the handoff has been read, every per-locale failure is isolated this way, so
2648
+ * callers should inspect {@link RunSummary.failed} and {@link RunSummary.partial} rather than
2649
+ * relying on a thrown error. A corrupt lock-file is the one exception, because it is a single
2650
+ * shared file rather than a per-locale one: it aborts the whole run even when it is discovered
2651
+ * after a locale has been applied, so the locales still to come are not written at all.
2055
2652
  *
2056
- * The lock-file is read once, up front, for every sheet's diff baseline. On a non-dry-run, each
2057
- * sheet's write-and-lock-update step then holds that locale's `withLocaleWriteLock` for its whole
2058
- * critical section, so a concurrent writer touching the same locale can never interleave with it.
2653
+ * @param input - The config and the handoff path, format, and dry-run flag.
2654
+ * @param deps - Optional adapter registry and file-system overrides.
2655
+ * @returns The per-locale account of what was applied.
2059
2656
  *
2060
- * @param input - The validated config, the workbook path, and run options.
2061
- * @param deps - Optional composition seams (registry, file system) for tests.
2062
- * @returns A {@link RunSummary} with one locale per data sheet, in workbook order.
2063
- * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`.
2657
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
2658
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the handoff file was not found, or the source
2659
+ * locale file does not exist.
2660
+ * @throws {@link SdkError} `SOURCE_INVALID`: the handoff is oversized or could not be parsed, or
2661
+ * the source locale file could not be parsed.
2662
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
2663
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
2664
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
2665
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
2666
+ * unsupported version. Read before any locale is applied, and re-read as each locale's entries are
2667
+ * recorded, so a lock-file that turns corrupt mid-run aborts the run rather than failing one locale.
2064
2668
  */
2065
2669
  declare function importWorkbook(input: ImportWorkbookInput, deps?: ImportWorkbookDeps): Promise<RunSummary>;
2066
2670
 
2067
- /** The locale styles a files pattern's `{locale}` token can expand under. */
2671
+ /** The locale spellings {@link LocaleStyle} is drawn from, in declaration order. */
2068
2672
  declare const LOCALE_STYLES: readonly ["literal", "posix", "android"];
2069
2673
  /**
2070
- * How a configured locale is spelled inside a path:
2674
+ * How a locale is spelled inside a file path, set through the config's `files.localeStyle`.
2071
2675
  *
2072
- * - `literal` (the default) expands the token to the configured tag verbatim. This covers the
2073
- * i18next, vue-i18n, next-intl, ngx-translate, XLIFF, YAML, and ARB layouts, and Apple's
2074
- * `.lproj` naming, which is the BCP-47 tag as written.
2075
- * - `posix` replaces `-` with `_`, for gettext directories (`pt_BR/LC_MESSAGES/`) and the Java
2076
- * `messages_{locale}.properties` suffix layout.
2077
- * - `android` expands the token to a complete Android resource-directory segment, `values` prefix
2078
- * included, and is the only style that spells the source locale differently from a target.
2676
+ * - `literal`: the locale is written into the path exactly as configured, so `pt-BR` yields
2677
+ * `pt-BR`. This is the default and suits the JSON and YAML layouts most web projects use.
2678
+ * - `posix`: the POSIX spelling, so `pt-BR` yields `pt_BR`. Common for gettext-influenced and
2679
+ * Java-influenced layouts.
2680
+ * - `android`: the Android resource-qualifier spelling, so `pt-BR` yields `values-pt-rBR` and the
2681
+ * source locale yields the unqualified `values`. This style expands to a whole path segment, so
2682
+ * the `{locale}` token must stand alone between separators in the pattern.
2079
2683
  *
2080
- * A style may be relaxed to accept a locale it previously refused, but the spelling it already
2081
- * produces must never change: a re-spelling orphans every file written under the old name, leaving
2082
- * a stale directory the platform still prefers.
2684
+ * A locale that has no valid spelling under the declared style, or that would expand to something
2685
+ * other than a single path segment, is rejected with `LOCALE_LAYOUT_INVALID` before any file is
2686
+ * read.
2083
2687
  */
2084
2688
  type LocaleStyle = (typeof LOCALE_STYLES)[number];
2085
2689
 
2086
2690
  /**
2087
- * The part of the project config locale-to-path resolution depends on. Deliberately structural, so a
2088
- * caller holding a full `VerbatraConfig` passes it unchanged while a test can build one inline.
2691
+ * The slice of a {@link VerbatraConfig} that determines locale-to-path mapping. It is narrower than
2692
+ * the full config on purpose, so a caller holding only these fields can build a resolver.
2089
2693
  */
2090
2694
  interface LocalePathResolverConfig {
2695
+ /** The source locale, which some styles spell differently from the targets. */
2091
2696
  readonly sourceLocale: string;
2697
+ /** Every target locale. All of them are mapped up front so collisions are caught immediately. */
2092
2698
  readonly targetLocales: readonly string[];
2699
+ /** The file layout. */
2093
2700
  readonly files: {
2701
+ /** The path pattern, which must contain the `{locale}` token. */
2094
2702
  readonly pattern: string;
2703
+ /** How a locale is spelled inside the path. Defaults to `literal`. */
2095
2704
  readonly localeStyle?: LocaleStyle | undefined;
2096
2705
  };
2097
2706
  }
2098
- /**
2099
- * The project's locale-to-path mapping, resolved once from the config and threaded through every
2100
- * flow that needs a path. Holding one object rather than a `(pattern, style, sourceLocale)` triple
2101
- * is what keeps a source-aware style from producing a wrong path in a flow that only threaded part
2102
- * of it.
2103
- */
2707
+ /** The two-way mapping between locales and their absolute file paths. */
2104
2708
  interface LocalePathResolver {
2105
2709
  /**
2106
- * The absolute file path for one locale. Never throws for a configured locale: every one of them
2107
- * is spelled and checked when the resolver is created.
2108
- *
2109
- * @throws SdkError `LOCALE_LAYOUT_INVALID` when the style cannot spell an unconfigured locale
2110
- * passed in after construction.
2710
+ * The absolute path of a locale's file. Accepts any locale string, including ones outside the
2711
+ * configured set, so it can be used for exploratory reads.
2111
2712
  */
2112
2713
  pathFor(locale: string): string;
2113
2714
  /**
2114
- * Which configured locale a path belongs to, or `undefined` when it belongs to none of them. This
2115
- * is a lookup in the forward map, never a parse: the configured locale list is the only authority
2116
- * on which locales exist. A relative argument is resolved against the same `cwd` the resolver was
2117
- * created with; the match is exact, so a path differing only in case does not resolve even on a
2118
- * case-insensitive file system.
2119
- *
2120
- * `undefined` is not an error. It means the path is not one of this project's locale files, which
2121
- * is what a watcher needs to know in order to ignore it.
2715
+ * The configured locale owning an absolute path, or `undefined` when the path belongs to no
2716
+ * configured locale. The path is normalized before lookup, so a relative or non-canonical path
2717
+ * still resolves.
2122
2718
  */
2123
2719
  localeFor(absolutePath: string): string | undefined;
2124
2720
  }
2125
2721
  /**
2126
- * Creates the project's locale-to-path resolver. Every check runs here, before any file is read and
2127
- * before any provider call: the pattern must carry the `{locale}` token and must be combinable with
2128
- * the declared style, the style must have a valid spelling for the source locale and every target,
2129
- * and no two locales may resolve to the same path.
2722
+ * Builds the project's locale-to-path mapping from the configured pattern, locales, and
2723
+ * {@link LocaleStyle}, in both directions.
2130
2724
  *
2131
- * Under the default `literal` style the produced path is the pattern with `{locale}` replaced by the
2132
- * configured tag, resolved against `cwd`, which is what every project without a `files.localeStyle`
2133
- * gets and is unchanged from before styles existed.
2725
+ * Every SDK flow resolves paths through this, and a consumer that watches or reports on locale
2726
+ * files should use it too rather than re-deriving the mapping: a watcher that guesses at paths will
2727
+ * disagree with the SDK the moment a non-literal locale style is configured.
2134
2728
  *
2135
- * @param cwd - Directory the files pattern resolves against.
2136
- * @param config - The project's locales, files pattern, and locale style.
2137
- * @throws SdkError `LOCALE_LAYOUT_INVALID` when the pattern and style cannot be combined, or the
2138
- * style cannot spell a configured locale.
2139
- * @throws SdkError `LOCALE_PATH_COLLISION` when two configured locales resolve to the same path.
2729
+ * The whole configured locale set is mapped eagerly during construction, so an unusable layout or a
2730
+ * path collision is reported here, before any file is read and before any provider is called,
2731
+ * rather than partway through a run.
2732
+ *
2733
+ * @param cwd - Directory the pattern is resolved against.
2734
+ * @param config - The source locale, target locales, and file layout.
2735
+ * @returns A resolver mapping locales to paths and paths back to locales.
2736
+ *
2737
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the pattern lacks the `{locale}` token, a
2738
+ * segment style's token does not stand alone between separators, or a configured locale has no
2739
+ * valid single-segment spelling under the declared style.
2740
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same
2741
+ * absolute path.
2140
2742
  */
2141
2743
  declare function createLocalePathResolver(cwd: string, config: LocalePathResolverConfig): LocalePathResolver;
2142
2744
 
2143
2745
  /**
2144
- * The lock-file shape: for each target locale, a map of key to the source content hash from which that
2145
- * target key was last translated. The baseline core's diff consumes to detect changed source strings.
2746
+ * The contents of `verbatra.lock.json` (see {@link LOCK_FILE_NAME}), the baseline that makes
2747
+ * staleness detectable. For each locale it records the hash of the source text each key was
2748
+ * translated from, so a later run can tell a translation that is merely present from one that is
2749
+ * still current.
2750
+ *
2751
+ * Commit it: without it, every run has to treat an existing translation as up to date and a source
2752
+ * edit would silently never be re-translated.
2146
2753
  */
2147
2754
  interface LockFile {
2755
+ /** The lock-file schema version. An unrecognized version is rejected rather than guessed at. */
2148
2756
  readonly version: number;
2757
+ /** Per locale, the source-content hash recorded for each translated key. */
2149
2758
  readonly locales: Readonly<Record<string, Readonly<Record<string, string>>>>;
2150
2759
  }
2151
2760
 
2152
- /** Input for {@link loadLockFile}: the directory the lock-file resolves against. */
2761
+ /** Input for {@link loadLockFile}. */
2153
2762
  interface LoadLockFileInput {
2154
- /** Directory the lock-file resolves against; defaults to cwd. */
2763
+ /** Directory holding the lock-file. Defaults to the process working directory. */
2155
2764
  readonly cwd?: string;
2156
2765
  }
2157
- /** Composition seam for {@link loadLockFile}: inject a file system for tests. */
2766
+ /** Injectable dependencies for {@link loadLockFile}. Every field has a working default. */
2158
2767
  interface LoadLockFileDeps {
2768
+ /** File-system port. Defaults to the real file system. */
2159
2769
  readonly fs?: SdkFs;
2160
2770
  }
2161
2771
  /**
2162
- * Read the project's lock-file: a thin public wrapper around the internal lock-file reader,
2163
- * following the same input/deps convention as {@link check}, {@link diff}, and {@link lockState}.
2164
- * A missing lock-file degrades to an empty lock (first-run), the same behavior `translate` and
2165
- * `watch` rely on internally. Callers that need to distinguish "no lock-file yet" from "an empty
2166
- * but present lock-file" should use {@link lockState} instead, which probes the file's existence
2167
- * explicitly.
2772
+ * Reads the project's lock-file. Use it when you need the recorded baseline hashes themselves;
2773
+ * {@link lockState} is the better choice when you want the drift those hashes imply.
2168
2774
  *
2169
- * @param input - The directory the lock-file resolves against.
2170
- * @param deps - Optional composition seam (file system) for tests.
2171
- * @returns The parsed lock-file, or an empty lock when none exists yet.
2172
- * @throws {@link SdkError} `LOCK_FILE_INVALID` when the lock-file is present but corrupt, oversized,
2173
- * or at an unsupported version.
2775
+ * A project with no lock-file yet reads as an empty lock-file at the current version rather than an
2776
+ * error, so a first run and a lost file behave the same way. A lock-file that exists but cannot be
2777
+ * trusted is a different matter and throws, because silently treating a corrupt baseline as empty
2778
+ * would make every key look stale and trigger a full re-translation.
2779
+ *
2780
+ * @param input - The optional working directory.
2781
+ * @param deps - Optional file-system override.
2782
+ * @returns The lock-file's contents, or an empty lock-file when none exists.
2783
+ *
2784
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file exists but is oversized, not valid
2785
+ * JSON, structurally wrong, or at an unsupported version.
2174
2786
  */
2175
2787
  declare function loadLockFile(input?: LoadLockFileInput, deps?: LoadLockFileDeps): Promise<LockFile>;
2176
2788
 
2177
- /** The committed lock-file name, chosen to be obviously JSON and to not match a `*.lock` ignore rule. */
2789
+ /**
2790
+ * The file name of the project's lock-file, resolved against the run's working directory. Commit it
2791
+ * alongside the locale files: it is the baseline that lets verbatra tell a stale translation from a
2792
+ * current one. See {@link LockFile} for its contents.
2793
+ */
2178
2794
  declare const LOCK_FILE_NAME = "verbatra.lock.json";
2179
2795
 
2180
2796
  /**
2181
- * The subset of {@link ProviderId} that `init` can scaffold: every provider with a single required
2182
- * environment variable. "openai-compatible" is deliberately excluded: unlike every other provider it
2183
- * has no single required environment variable, only a three-tier
2184
- * apiKeyEnvVar/OPENAI_COMPATIBLE_API_KEY/placeholder fallback (see resolveOpenAiCompatibleKey in
2185
- * @verbatra/ai-providers), so it does not fit the `providerEnv` table below and `init` scaffolding does
2186
- * not offer it. This is the single source of truth for the exclusion; other packages import this type
2187
- * rather than re-deriving `Exclude<ProviderId, "openai-compatible">` themselves.
2797
+ * A provider that project scaffolding can offer out of the box. It excludes `openai-compatible`,
2798
+ * which needs a `baseUrl` and a model name that only the user can supply, so there is nothing
2799
+ * sensible to prefill.
2188
2800
  */
2189
2801
  type ScaffoldableProviderId = Exclude<ProviderId, "openai-compatible">;
2190
2802
  /**
2191
- * Read-only metadata the CLI `init` scaffold derives its tables from, so the CLI never restates the
2192
- * provider, env-var, model, or format truth owned by core and ai-providers.
2803
+ * The facts a project generator needs to write a first config: which environment variable each
2804
+ * provider reads its API key from, a sensible starting model per provider, and the formats the SDK
2805
+ * can handle.
2806
+ *
2807
+ * It is exported so that the CLI's `init` command and any third-party generator prompt with the
2808
+ * same values the SDK actually enforces, rather than keeping a copy that drifts. Note the key names
2809
+ * only: no key value is present or reachable here.
2193
2810
  */
2194
2811
  declare const scaffoldingMetadata: {
2195
- /** Provider id -> the environment variable its API key is read from. Owned by ai-providers. */
2812
+ /** The environment variable each scaffoldable provider reads its API key from. */
2196
2813
  readonly providerEnv: {
2197
2814
  readonly anthropic: "ANTHROPIC_API_KEY";
2198
2815
  readonly openai: "OPENAI_API_KEY";
2199
2816
  readonly gemini: "GEMINI_API_KEY";
2200
2817
  readonly deepl: "DEEPL_API_KEY";
2201
2818
  };
2202
- /** LLM provider id -> a cosmetic default scaffold model. Owned by ai-providers. DeepL has none. */
2819
+ /** A reasonable default model to prefill per language-model provider. DeepL has none, since it takes no model. */
2203
2820
  readonly scaffoldModels: {
2204
2821
  readonly anthropic: "claude-sonnet-4-6";
2205
2822
  readonly openai: "gpt-5.4-mini";
2206
2823
  readonly gemini: "gemini-2.5-flash";
2207
2824
  };
2208
- /** The closed set of source format ids. Owned by core. */
2825
+ /**
2826
+ * The option key each language-model provider takes its output token limit under, since they do
2827
+ * not agree: Anthropic calls it `maxTokens` and the others `maxOutputTokens`. A generator that
2828
+ * prefills a token limit must read the key from here rather than assume one, because
2829
+ * {@link verbatraConfigSchema} validates each provider's options strictly and rejects the wrong
2830
+ * one. DeepL has no entry, since it takes no token limit.
2831
+ */
2832
+ readonly providerTokenLimitKeys: {
2833
+ readonly anthropic: "maxTokens";
2834
+ readonly openai: "maxOutputTokens";
2835
+ readonly gemini: "maxOutputTokens";
2836
+ };
2837
+ /** Every i18n file format the SDK can read and write. */
2209
2838
  readonly supportedFormats: readonly ["i18next-json", "vue-i18n-json", "next-intl-json", "ngx-translate-json", "xliff", "yaml", "arb", "properties"];
2210
2839
  };
2211
2840
 
2212
- /** A minimal source-change event source. Production wraps chokidar; tests inject a stub. */
2841
+ /**
2842
+ * The minimal file-watching surface {@link watch} needs. Supplying your own through
2843
+ * `deps.createWatcher` lets an embedding application reuse a watcher it already runs, or drive the
2844
+ * flow deterministically in a test.
2845
+ */
2213
2846
  interface Watcher {
2214
- /** Register a listener invoked once per coalesced source-change event. */
2847
+ /** Registers a listener called on each change to the watched paths. */
2215
2848
  onChange(listener: () => void): void;
2216
- /** Stop watching and release the underlying resources. */
2849
+ /** Stops watching and releases the underlying resources. */
2217
2850
  close(): Promise<void>;
2218
2851
  }
2219
- /** Builds a {@link Watcher} for the given paths; the seam production fills with chokidar. */
2852
+ /** Builds a {@link Watcher} over the given absolute paths. Defaults to a chokidar-backed watcher. */
2220
2853
  type CreateWatcher = (paths: readonly string[]) => Watcher;
2221
- /** The run a watch trigger performs: the one-shot translate, unchanged. */
2854
+ /** Runs one translation pass. Defaults to {@link translate}, and is the seam a test substitutes. */
2222
2855
  type RunTranslate = (input: TranslateInput) => Promise<RunSummary>;
2223
- /** The outcome of one run, surfaced to the caller; never carries a secret. */
2856
+ /**
2857
+ * The outcome of a single watch-triggered run, handed to `onRun`. A failed run is reported here
2858
+ * rather than thrown, because a long-lived watcher must survive a bad run and keep watching.
2859
+ */
2224
2860
  type WatchRunResult = {
2861
+ /** The run completed. Inspect the summary for per-locale outcomes, which may still include failures. */
2225
2862
  readonly status: "succeeded";
2863
+ /** The run's per-locale account. */
2226
2864
  readonly summary: RunSummary;
2227
2865
  } | {
2866
+ /** The run threw before producing a summary, for instance because the source became unreadable. */
2228
2867
  readonly status: "failed";
2229
- /**
2230
- * A secret-free projection of the run's failure. `code` is a preserved string (the underlying
2231
- * error's `code`, or `"WATCH_RUN_FAILED"` as a fallback), not an {@link SdkErrorCode}.
2232
- */
2868
+ /** The failure's code and message. Never carries a secret. */
2233
2869
  readonly error: {
2870
+ /** The failure's own code where it had one, and `WATCH_RUN_FAILED` otherwise. */
2234
2871
  readonly code: string;
2872
+ /** A human-readable description of the failure. Never contains a secret. */
2235
2873
  readonly message: string;
2236
2874
  };
2237
2875
  };
2238
- /** Everything watch mode needs: the config, optional cwd/debounce, and the per-run output callback. */
2876
+ /** Input for {@link watch}. */
2239
2877
  interface WatchInput {
2240
- /** The validated configuration (typically from {@link loadConfig}). */
2878
+ /** The resolved project config, normally from {@link loadConfig}. */
2241
2879
  readonly config: VerbatraConfig;
2242
- /** Directory the file pattern and lock-file resolve against; defaults to the current working directory. */
2880
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
2243
2881
  readonly cwd?: string;
2244
- /** Quiet period after the last change before a run fires; defaults to 300ms. */
2245
- readonly debounceMs?: number;
2246
- /** Called once per run with its result. The SDK does no logging; this is the only output. */
2247
- readonly onRun: (result: WatchRunResult) => void;
2248
2882
  /**
2249
- * Passed through to every run's {@link TranslateInput.onLockWait}: called while a locale's write lock
2250
- * is blocked on another process, so a watch caller can surface the same "still waiting" progress a
2251
- * one-shot run does.
2883
+ * Restrict every run of the session to a subset of the configured target locales. Validated once
2884
+ * when watching starts, so an unconfigured locale throws `UNKNOWN_LOCALE` before any watching
2885
+ * begins rather than failing on each run. An explicit empty array selects no locale at all.
2252
2886
  */
2253
- readonly onLockWait?: LockWaitListener;
2887
+ readonly locales?: readonly string[];
2888
+ /** How long to coalesce rapid source changes before running. Defaults to 300 milliseconds. */
2889
+ readonly debounceMs?: number;
2254
2890
  /**
2255
- * Passed through to every run's {@link TranslateInput.onProgress}: fires per locale and per provider
2256
- * sub-batch as each run advances, so a watch caller can surface the same progress a one-shot run does.
2891
+ * Called after every run with its outcome. This is the only way to observe a watch session, since
2892
+ * {@link watch} itself resolves as soon as watching starts.
2257
2893
  */
2894
+ readonly onRun: (result: WatchRunResult) => void;
2895
+ /** Called while waiting on another process's write lock. */
2896
+ readonly onLockWait?: LockWaitListener;
2897
+ /** Called as locales and sub-batches start and finish, for progress reporting. */
2258
2898
  readonly onProgress?: ProgressListener;
2259
- /** Passed through to every run's {@link TranslateInput.lockAcquireTimeoutMs}; the lock's 10-minute default when unset. */
2899
+ /** How long to wait for a locale's write lock before that run fails. */
2260
2900
  readonly lockAcquireTimeoutMs?: number;
2261
- /**
2262
- * Passed through to every run's {@link TranslateInput.concurrency}: how many locales each cycle may
2263
- * run at once. Defaults to 1 (strictly serial). Because the per-cycle budget tracker is fresh, a
2264
- * value greater than 1 cannot be honored alongside a `maxTokens` budget; {@link watch} refuses that
2265
- * combination at startup rather than letting every cycle fail on it.
2266
- */
2901
+ /** How many locales to run at once. Validated once when watching starts, and applied to every run. */
2267
2902
  readonly concurrency?: number;
2268
- /**
2269
- * Passed through to every run's {@link TranslateInput.cache}: when false, each run bypasses the
2270
- * translation-memory cache (both read and write). On by default.
2271
- */
2903
+ /** Consult and update the translation memory. Defaults to true. */
2272
2904
  readonly cache?: boolean;
2273
2905
  }
2274
- /** Composition seam: inject the watcher and the run for deterministic, offline tests. */
2906
+ /** Injectable dependencies for {@link watch}. Every field has a working default. */
2275
2907
  interface WatchDeps {
2276
- /** Adapter registry passed through to each run; defaults to the built-in registry. */
2908
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
2277
2909
  readonly adapterRegistry?: AdapterRegistry;
2278
- /** Provider builder passed through to each run; defaults to constructing the configured provider. */
2910
+ /** Provider factory. Defaults to constructing the provider named in the config. */
2279
2911
  readonly createProvider?: CreateProvider;
2280
- /** File system passed through to each run; defaults to the real file system. */
2912
+ /** File-system port. Defaults to the real file system. */
2281
2913
  readonly fs?: SdkFs;
2282
- /** Source-change event source; defaults to the chokidar-backed watcher. */
2914
+ /** Watcher factory. Defaults to a chokidar-backed watcher over the source file. */
2283
2915
  readonly createWatcher?: CreateWatcher;
2284
- /** The run a trigger performs; defaults to the one-shot {@link translate}. */
2916
+ /** Translation runner. Defaults to {@link translate} wired with the other dependencies. */
2285
2917
  readonly runTranslate?: RunTranslate;
2286
2918
  }
2287
- /** Handle returned by {@link watch} to stop it. */
2919
+ /** The handle returned by {@link watch}, used to end the session. */
2288
2920
  interface WatchController {
2289
- /** Stop accepting triggers, close the watcher, and await the in-flight run to completion. */
2921
+ /** Stops watching and waits for any in-flight run to settle. Safe to call more than once. */
2290
2922
  stop(): Promise<void>;
2291
2923
  }
2292
2924
  /**
2293
- * Start watching the source file and re-run the one-shot {@link translate} on each debounced change.
2294
- * Runs are serialized: changes during a run collapse into a single follow-up, so two runs never
2295
- * overlap. A missing source at startup throws; every run outcome after start is surfaced through
2296
- * `onRun` as a {@link WatchRunResult} and watching continues. Returns a controller whose `stop()`
2297
- * closes the watcher and awaits the in-flight run.
2298
- *
2299
- * Both startup refusals are ordered deliberately: `concurrency` is resolved first because it is
2300
- * decidable from the arguments alone, while the missing-source check is an I/O probe of project
2301
- * state, so argument validation runs before any I/O and before the watcher exists. The concurrency
2302
- * resolve passes `dryRun: false` unconditionally, which is exact rather than a simplification: there
2303
- * is no dry-run watch, so the budget conflict always applies to a session.
2304
- *
2305
- * Resolving it here rather than leaving it to each cycle is the point, not an optimization: a watch
2306
- * session is long-lived, so a per-cycle refusal would fail the initial run and every run after it,
2307
- * indefinitely, while the session stayed alive and looked healthy.
2308
- *
2309
- * @param input - The config, optional cwd/debounce, and the `onRun` callback that receives each result.
2310
- * @param deps - Optional composition seams (watcher, run, registry, provider builder, file system) for tests.
2311
- * @returns A {@link WatchController}; call `stop()` to close the watcher and await the in-flight run.
2312
- * @throws {@link SdkError} `CONCURRENCY_INVALID`: at startup only, when `concurrency` is defined but
2313
- * not an integer of at least 1.
2314
- * @throws {@link SdkError} `CONCURRENCY_BUDGET_CONFLICT`: at startup only, when `concurrency` is
2315
- * greater than 1 while the config sets a `maxTokens` budget.
2316
- * @throws {@link SdkError} `SOURCE_UNREADABLE`: at startup only, when the source locale file is absent.
2925
+ * Watches the source locale file and re-runs {@link translate} on each debounced change. It is the
2926
+ * development-loop counterpart to a one-shot run: edit a string, and the target locales catch up
2927
+ * without a manual command.
2928
+ *
2929
+ * One run starts immediately once watching is established, without waiting for a change, so the
2930
+ * target locales are brought up to date the moment the session opens. Its outcome arrives through
2931
+ * `onRun` like any other, and it may still be in flight when the returned promise resolves.
2932
+ *
2933
+ * The returned promise resolves as soon as watching is established, not when translation finishes,
2934
+ * so every run outcome arrives through `onRun`. A run that throws is delivered as a failed
2935
+ * {@link WatchRunResult} rather than escaping the session, because a watcher that died on the first
2936
+ * bad save would be useless. Only startup problems throw: the inputs validated before any watching
2937
+ * begins, and a failure to construct the watcher itself.
2938
+ *
2939
+ * Rapid saves are coalesced by `debounceMs`, and runs never overlap, so an editor writing a file
2940
+ * several times in a moment produces one run rather than a queue of them.
2941
+ *
2942
+ * Call {@link WatchController.stop} to end the session.
2943
+ *
2944
+ * @param input - The config, the debounce window, and the run callback.
2945
+ * @param deps - Optional adapter registry, provider factory, file-system, watcher, and runner overrides.
2946
+ * @returns A controller that stops the session.
2947
+ *
2948
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: `locales` names a locale that is not a configured
2949
+ * target. Thrown once at startup, before any watching begins.
2950
+ * @throws {@link SdkError} `CONCURRENCY_INVALID`: `concurrency` is not an integer of at least 1.
2951
+ * @throws {@link SdkError} `CONCURRENCY_BUDGET_CONFLICT`: `concurrency` above 1 was combined with a
2952
+ * configured token budget.
2953
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
2954
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
2955
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
2956
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist when watching
2957
+ * starts.
2958
+ * @throws Whatever the watcher factory raised, unwrapped, when it could not build a watcher over
2959
+ * the source file. It is not wrapped as an {@link SdkError}. No run has started at that point, so
2960
+ * nothing is watched and `onRun` is never called.
2961
+ *
2317
2962
  * @example
2318
2963
  * ```ts
2319
2964
  * import { loadConfig, watch } from "@verbatra/sdk";
2320
2965
  *
2321
- * // The provider reads its API key from the environment (e.g. ANTHROPIC_API_KEY); no key is passed here.
2322
2966
  * const config = await loadConfig();
2323
2967
  * const controller = await watch({
2324
2968
  * config,
2325
2969
  * onRun: (result) => {
2326
- * if (result.status === "succeeded") {
2327
- * console.log(`ran: ${result.summary.succeeded.length} ok, ${result.summary.failed.length} failed`);
2328
- * } else {
2329
- * // Surfaced, not thrown: code is a preserved string (WATCH_RUN_FAILED is only the fallback).
2330
- * console.error(`run failed: ${result.error.code} ${result.error.message}`);
2970
+ * if (result.status === "failed") {
2971
+ * console.error(`run failed: ${result.error.message}`);
2972
+ * return;
2331
2973
  * }
2974
+ * console.log(`translated ${result.summary.succeeded.length} locales`);
2332
2975
  * },
2333
2976
  * });
2334
2977
  *
2335
- * // Stop cleanly on Ctrl-C: closes the watcher and awaits the in-flight run.
2336
- * process.on("SIGINT", () => {
2337
- * void controller.stop();
2338
- * });
2978
+ * process.on("SIGINT", () => void controller.stop());
2339
2979
  * ```
2340
2980
  */
2341
2981
  declare function watch(input: WatchInput, deps?: WatchDeps): Promise<WatchController>;
2342
2982
 
2343
- export { type BudgetBehavior, CACHE_FILE_NAME, type CheckDeps, type CheckInput, type CheckSummary, type ConfigSource, type CreateProvider, type CreateWatcher, DEFAULT_DELIMITED_PATH, DEFAULT_WORKBOOK_PATH, type DiffDeps, type DiffInput, type DiffSummary, type DuplicateKeyReport, type EditEntryDeps, type EditEntryInput, type EditEntryResult, type ExchangeFormat, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type GlossaryProvenance, type ImportWorkbookDeps, type ImportWorkbookInput, type IntegrityGateReason, 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 LocaleFinishedEvent, type LocaleKeyIntegrity, type LocaleNotice, type LocalePathResolver, type LocalePathResolverConfig, type LocaleSnapshotDelta, type LocaleStartedEvent, type LocaleStyle, type LocaleSummary, type LockFile, type LockHolder, type LockLocaleState, type LockStateDeps, type LockStateInput, type LockStateResult, type LockWaitEvent, type LockWaitListener, type MalformedRowReport, type NeedsReviewEntry, type ProgressEvent, type ProgressListener, type ProviderConfig, type ProviderId, type ReadLocaleFileSnapshotDeps, type ReadLocaleFileSnapshotInput, type RetranslateEntryDeps, type RetranslateEntryInput, type RetranslateEntryResult, type ReviewReasonCode, type RunBudget, type RunFinishedEvent, 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 SubBatchProgressEvent, type SupportedFormat$1 as SupportedFormat, type TranslateDeps, type TranslateInput, type TranslationMemory, type UsageSummary, type VerbatraConfig, type VerbatraConfigInput, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, check, createLocalePathResolver, defineConfig, diff, diffLocaleSnapshots, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate, verbatraConfigSchema, watch };
2983
+ export { type BudgetBehavior, CACHE_FILE_NAME, type CheckDeps, type CheckInput, type CheckSummary, type ConfigSource, type CreateProvider, type CreateWatcher, DEFAULT_DELIMITED_PATH, DEFAULT_EXCHANGE_FORMAT, DEFAULT_WORKBOOK_PATH, type DiffDeps, type DiffInput, type DiffSummary, type DoctorCheck, type DoctorCheckId, type DoctorCheckStatus, type DoctorDeps, type DoctorInput, type DoctorResult, type DuplicateKeyReport, EXCHANGE_FORMATS, type EditEntryDeps, type EditEntryInput, type EditEntryResult, type ExchangeFormat, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type GlossaryFileDeps, type GlossaryFileInput, type GlossaryProvenance, type ImportWorkbookDeps, type ImportWorkbookInput, type IntegrityGateReason, 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 LocaleFinishedEvent, type LocaleKeyIntegrity, type LocaleNotice, type LocalePathResolver, type LocalePathResolverConfig, type LocaleSnapshotDelta, type LocaleStartedEvent, type LocaleStyle, type LocaleSummary, type LockFile, type LockHolder, type LockLocaleState, type LockStateDeps, type LockStateInput, type LockStateResult, type LockWaitEvent, type LockWaitListener, type MalformedRowReport, type NeedsReviewEntry, type ProgressEvent, type ProgressListener, type ProviderConfig, type ProviderId, type ReadLocaleFileSnapshotDeps, type ReadLocaleFileSnapshotInput, type RetranslateEntryDeps, type RetranslateEntryInput, type RetranslateEntryResult, type ReviewReasonCode, type RunBudget, type RunFinishedEvent, 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 SubBatchProgressEvent, type SupportedFormat$1 as SupportedFormat, type TranslateDeps, type TranslateInput, type TranslationMemory, type UpdateGlossaryTermInput, type UsageSummary, type VerbatraConfig, type VerbatraConfigInput, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, check, createLocalePathResolver, defineConfig, diff, diffLocaleSnapshots, doctor, editEntry, exportWorkbook, importWorkbook, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, lockState, readGlossaryFile, readLocaleFileSnapshot, retranslateEntry, runStatus, scaffoldingMetadata, translate, updateGlossaryTerm, verbatraConfigSchema, watch };