@verbatra/sdk 0.9.3 → 0.11.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
@@ -17,7 +17,7 @@ import OpenAI from 'openai';
17
17
  * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
18
18
  * decides and which feeds the content hash.
19
19
  */
20
- declare const translationEntrySchema$1: z.ZodObject<{
20
+ declare const translationEntrySchema$2: z.ZodObject<{
21
21
  key: z.ZodString;
22
22
  namespace: z.ZodString;
23
23
  value: z.ZodString;
@@ -30,14 +30,14 @@ declare const translationEntrySchema$1: z.ZodObject<{
30
30
  * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
31
31
  * made deeply readonly. See that schema for what each field carries.
32
32
  */
33
- type TranslationEntry$1 = Readonly<z.infer<typeof translationEntrySchema$1>>;
33
+ type TranslationEntry$2 = Readonly<z.infer<typeof translationEntrySchema$2>>;
34
34
 
35
35
  /**
36
36
  * Result of comparing source placeholders against translated-output placeholders as MULTISETS
37
37
  * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
38
38
  * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
39
39
  */
40
- interface PlaceholderIntegrityResult$1 {
40
+ interface PlaceholderIntegrityResult$2 {
41
41
  /** True when nothing is missing or extra: the multisets match, regardless of order. */
42
42
  readonly matches: boolean;
43
43
  /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
@@ -74,7 +74,7 @@ type PlaceholderExtractor = (value: string) => readonly string[];
74
74
  * defines one) so it matches the entries' format; ai-providers never derives it itself and never parses
75
75
  * a specific format's message syntax.
76
76
  */
77
- type PlaceholderComparator = (source: string, translated: string) => PlaceholderIntegrityResult$1;
77
+ type PlaceholderComparator = (source: string, translated: string) => PlaceholderIntegrityResult$2;
78
78
  /**
79
79
  * A batch translation request. Format- and provider-neutral: it carries no prompt,
80
80
  * model, key, or other provider-specific field. The placeholder extractor is
@@ -86,11 +86,18 @@ interface TranslateRequest {
86
86
  /** BCP-47 target locale to translate into (for example, "de"). */
87
87
  readonly targetLocale: string;
88
88
  /** The entries to translate; at least one is required. */
89
- readonly entries: readonly TranslationEntry$1[];
89
+ readonly entries: readonly TranslationEntry$2[];
90
90
  /** Optional source-term to target-term map applied by glossary-capable providers. */
91
91
  readonly glossary?: Readonly<Record<string, string>>;
92
92
  /** Optional target {@link Tone}; machine-translation providers map it to formality. */
93
93
  readonly tone?: Tone;
94
+ /**
95
+ * Optional per-key maximum length budget, measured in grapheme clusters. A key present in the map
96
+ * whose translation comes back longer than its budget is flagged `MAX_LENGTH_EXCEEDED`; a key
97
+ * absent from the map is never flagged. Advisory only: it never changes what is sent to the
98
+ * provider and never withholds a value.
99
+ */
100
+ readonly maxLength?: ReadonlyMap<string, number>;
94
101
  /** Mandatory placeholder extractor; the output integrity check runs against it. */
95
102
  readonly extractPlaceholders: PlaceholderExtractor;
96
103
  /**
@@ -141,24 +148,65 @@ interface ProviderNotice {
141
148
  }
142
149
  /**
143
150
  * Stable codes for a derived, per-key "needs review" signal. This is verbatra's own computed
144
- * assessment, never a raw model self-score: four are recomputable from plain source and translated
145
- * values, and `PROVIDER_DEGRADED` is layered on afterwards from the batch's notices.
151
+ * assessment, never a raw model self-score: five are recomputable from plain source and translated
152
+ * values, and `FUZZY_CACHE_REUSE` and `PROVIDER_DEGRADED` are layered on afterwards, the first by
153
+ * the SDK from where the value came, the second from the batch's notices.
146
154
  *
147
155
  * - `LENGTH_RATIO_OUTLIER`: the translated value's length is far shorter or longer than the source's.
148
- * Only considered once the trimmed source is long enough for the ratio to mean anything.
156
+ * Only considered once the trimmed source is long enough for the ratio to mean anything. Relative
157
+ * to the source, and measured in UTF-16 code units; contrast `MAX_LENGTH_EXCEEDED`.
158
+ * - `MAX_LENGTH_EXCEEDED`: the translated value is longer than the absolute budget configured for
159
+ * its key. Measured in grapheme clusters (user-perceived characters), on the value exactly as
160
+ * written with no trimming, and the comparison is inclusive, so a value of exactly the budget is
161
+ * not flagged. A key with no configured budget is never flagged. Advisory only: an over-budget
162
+ * value is still written to the locale file and still recorded in the lock file, because a
163
+ * correct translation that overruns a layout budget is more useful than no translation at all.
149
164
  * - `EQUALS_SOURCE`: the translated value equals the source value once both are trimmed, so a
150
165
  * difference in leading or trailing whitespace alone still counts as equal. Also requires that
151
166
  * the locales differ and that the value contains at least one letter (so a bare symbol or number
152
167
  * is not flagged).
153
168
  * - `GLOSSARY_TERM_MISSED`: a configured glossary source term appeared in the source but its target
154
- * term did not appear in the translation.
169
+ * term did not appear in the translation. Matching is case-insensitive, and the two sides are
170
+ * held to deliberately different standards. The source side requires a whole-word occurrence, so
171
+ * the term "AI" is not found inside "Airport" and cannot raise an expectation the translator was
172
+ * never given; a source term whose edge character belongs to a script written without word
173
+ * separators (Han, kana, Thai and similar) has no boundary to anchor to and falls back to
174
+ * containment. The target side requires containment only, because a translated term legitimately
175
+ * fuses with the text around it: German compounds ("Benutzerkonto"), Korean particles ("계정을")
176
+ * and Japanese loanwords all carry the term with no boundary around it, and demanding one there
177
+ * would flag correct translations in most languages a glossary is used for.
155
178
  * - `INTEGRITY_REORDERED`: the placeholder set matched but landed in a different order.
156
179
  * - `PROVIDER_DEGRADED`: the batch this key came from carried a `FORMALITY_DOWNGRADED` or
157
180
  * `GLOSSARY_IGNORED` notice, either of which can silently change wording. A
158
181
  * `PLACEHOLDER_UNSUPPORTED` notice does not raise it, since the affected entries are withheld
159
182
  * rather than degraded.
183
+ * - `FUZZY_CACHE_REUSE`: the value was not translated for this source text at all. It was reused
184
+ * from the translation memory for an earlier, near-identical source that has since been edited,
185
+ * so it is a translation of text that is no longer the source text. Similarity is measured in
186
+ * characters and meaning is not a function of character distance: a dropped negation, an
187
+ * inverted modal or a swapped proper noun are all small edits with large consequences, and no
188
+ * threshold separates them from a typo fix. Every such reuse therefore carries this reason, on
189
+ * every run, regardless of score.
190
+ *
191
+ * This tuple is the single source of truth for the set. {@link ReviewReasonCode} is derived from
192
+ * it, so build any runtime validator or exhaustive lookup from this value rather than retyping the
193
+ * members; a hand-copied list silently falls behind the next addition.
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * import { REVIEW_REASON_CODES } from "@verbatra/sdk";
198
+ * import { z } from "zod";
199
+ *
200
+ * const reasonSchema = z.enum(REVIEW_REASON_CODES);
201
+ * ```
202
+ */
203
+ declare const REVIEW_REASON_CODES: readonly ["LENGTH_RATIO_OUTLIER", "MAX_LENGTH_EXCEEDED", "EQUALS_SOURCE", "GLOSSARY_TERM_MISSED", "INTEGRITY_REORDERED", "PROVIDER_DEGRADED", "FUZZY_CACHE_REUSE"];
204
+ /**
205
+ * One of {@link REVIEW_REASON_CODES}. The union is derived from that tuple rather than written out
206
+ * again, so a code can only be added in one place and every consumer that builds a runtime schema
207
+ * or an exhaustive map from the tuple stays in step automatically.
160
208
  */
161
- type ReviewReasonCode = "LENGTH_RATIO_OUTLIER" | "EQUALS_SOURCE" | "GLOSSARY_TERM_MISSED" | "INTEGRITY_REORDERED" | "PROVIDER_DEGRADED";
209
+ type ReviewReasonCode = (typeof REVIEW_REASON_CODES)[number];
162
210
  /** A key flagged for human review, carrying every reason code that applies. */
163
211
  interface ReviewFlag {
164
212
  /** Always "review": the flag exists only for flagged keys, so there is no "ok" member. */
@@ -175,7 +223,7 @@ interface TranslateResult {
175
223
  */
176
224
  readonly values: ReadonlyMap<string, string>;
177
225
  /** The placeholder-integrity outcome for each key (source vs translated placeholder sets). */
178
- readonly integrity: ReadonlyMap<string, PlaceholderIntegrityResult$1>;
226
+ readonly integrity: ReadonlyMap<string, PlaceholderIntegrityResult$2>;
179
227
  /** Token usage when the provider reports it; absent for token-less providers. */
180
228
  readonly usage?: Usage;
181
229
  /**
@@ -260,16 +308,26 @@ type GeminiModel = "gemini-2.5-flash" | "gemini-2.5-pro" | "gemma-4-26b-a4b-it"
260
308
  type OpenAiModel = OpenAI.ChatModel;
261
309
 
262
310
  /**
263
- * Zod schema accepting exactly one of the eight source formats verbatra can read and write. Each
311
+ * Zod schema accepting exactly one of the fourteen source formats verbatra can read and write. Each
264
312
  * member names the ecosystem it round-trips, not just a file extension, because several of them share
265
313
  * one: `i18next-json` is i18next's nested JSON with its plural key suffixes, `vue-i18n-json` is Vue
266
314
  * I18n's JSON with its pipe-separated plural values, `next-intl-json` is next-intl's ICU-message JSON,
267
- * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining four are `xliff` (the XLIFF
268
- * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle), and
269
- * `properties` (Java and Spring `.properties` files).
270
- *
271
- * The set is closed: a format outside it cannot be represented, so adding one means adding a member
272
- * here and an adapter that claims it.
315
+ * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining ten are `xliff` (the XLIFF
316
+ * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle),
317
+ * `properties` (Java and Spring `.properties` files), `apple-strings` (Apple's flat `.strings`
318
+ * localization format for iOS and macOS), `apple-xcstrings` (Apple's Xcode String Catalog, a single
319
+ * JSON document holding every locale of a `.xcstrings` catalogue together), `android-xml` (Android's
320
+ * `res/values/strings.xml` resource format, including `<plurals>` and `translatable="false"` entries),
321
+ * `gettext-po` (GNU gettext's `.po` and `.pot` catalogs, including `msgctxt` disambiguation and
322
+ * `msgid_plural`/`msgstr[n]` plural forms keyed by the file's own `Plural-Forms` index count),
323
+ * `ini` (classic INI configuration files, one level of `[section]` headers over `key=value` lines,
324
+ * whose entries are addressed as `section.key`), and `resx` (.NET's XML resource format; its `<data>`
325
+ * elements carrying a `type` or `mimetype` attribute, and its designer metadata names, are preserved
326
+ * untouched rather than translated).
327
+ *
328
+ * The set is closed: adding a built-in format means adding a member here and an adapter that claims
329
+ * it. A format shipped by a package outside verbatra is named by a `custom:` identifier instead and
330
+ * never joins this set; see `FormatId`.
273
331
  */
274
332
  declare const supportedFormatSchema$1: z.ZodEnum<{
275
333
  "i18next-json": "i18next-json";
@@ -280,196 +338,1250 @@ declare const supportedFormatSchema$1: z.ZodEnum<{
280
338
  yaml: "yaml";
281
339
  arb: "arb";
282
340
  properties: "properties";
341
+ "apple-strings": "apple-strings";
342
+ "apple-xcstrings": "apple-xcstrings";
343
+ "android-xml": "android-xml";
344
+ "gettext-po": "gettext-po";
345
+ ini: "ini";
346
+ resx: "resx";
283
347
  }>;
284
- /** One of the eight supported source formats; the inferred type of {@link supportedFormatSchema}. */
348
+ /** One of the fourteen supported source formats; the inferred type of {@link supportedFormatSchema}. */
285
349
  type SupportedFormat$1 = z.infer<typeof supportedFormatSchema$1>;
286
-
287
350
  /**
288
- * The outcome of a size-bounded text read. A file that is absent or larger than the caller's limit
289
- * is reported as a state rather than thrown, so a caller can treat "no file yet" as normal and an
290
- * oversized file as a distinct, actionable condition.
351
+ * A format identifier claimed by an adapter that ships outside verbatra: the reserved
352
+ * `custom:` prefix followed by a lowercase, hyphen-separated name, for example
353
+ * `custom:my-format`.
354
+ *
355
+ * No built-in format name contains a colon, so a third-party identifier can never shadow a
356
+ * built-in one.
291
357
  */
292
- type BoundedFileRead = {
293
- /** The file was read in full. */
294
- readonly kind: "ok";
295
- /** The file's content, decoded as UTF-8. */
296
- readonly content: string;
297
- } | {
298
- /** No readable file exists at the path. */
299
- readonly kind: "missing";
300
- } | {
301
- /** The file exists but exceeds the requested byte limit, so nothing was read. */
302
- readonly kind: "too-large";
303
- };
358
+ type CustomFormatId$1 = `custom:${string}`;
304
359
  /**
305
- * The outcome of a size-bounded binary read, used for interchange workbooks. Mirrors
306
- * {@link BoundedFileRead} but yields raw bytes rather than decoded text.
360
+ * Narrow a format identifier to a third-party one.
361
+ *
362
+ * @param format - The identifier to classify.
363
+ * @returns True when the identifier is a well-formed `custom:` identifier. A built-in format
364
+ * name always returns false.
365
+ *
366
+ * @example
367
+ * ```ts
368
+ * isCustomFormatId("custom:toml"); // true
369
+ * isCustomFormatId("i18next-json"); // false
370
+ * ```
307
371
  */
308
- type BoundedBytesRead = {
309
- /** The file was read in full. */
310
- readonly kind: "ok";
311
- /** The file's raw bytes. */
312
- readonly bytes: Uint8Array;
313
- } | {
314
- /** No readable file exists at the path. */
315
- readonly kind: "missing";
316
- } | {
317
- /** The file exists but exceeds the requested byte limit, so nothing was read. */
318
- readonly kind: "too-large";
319
- };
372
+ declare function isCustomFormatId(format: string): format is CustomFormatId$1;
320
373
  /**
321
- * The file-system port the SDK's own I/O goes through. Supplying your own implementation as a
322
- * `deps.fs` option redirects that I/O, which is how the SDK's tests avoid touching disk and how an
323
- * embedding application can back part of a project with something other than a local disk.
374
+ * Any format verbatra can be asked to handle: one of the built-in {@link SupportedFormat}
375
+ * members, or a third-party {@link CustomFormatId}.
324
376
  *
325
- * The seam carries every file the SDK touches: the run-status file, the lock-file, the config
326
- * glossary, workbook and interchange I/O, and the locale files themselves, which the adapters read
327
- * and write through a port built from this one. The single exception is a caller-supplied
328
- * `deps.adapterRegistry`: those adapters were constructed by the caller, so their file access is
329
- * whatever the caller wired into them, and supplying both means the caller owns that wiring.
377
+ * A built-in name is checked against the closed set at compile time. A `custom:` identifier is
378
+ * only checked for shape, because the adapter behind it lives outside this package; if no
379
+ * registered adapter claims it, the run fails with a structured error naming the format.
380
+ */
381
+ type FormatId$1 = SupportedFormat$1 | CustomFormatId$1;
382
+
383
+ /**
384
+ * A single, format-neutral translation unit. Placeholders are supplied already extracted; core never
385
+ * derives them from the value, so an adapter that reads a file is the one authority on what counts as
386
+ * a placeholder in that format.
330
387
  *
331
- * Reads are size-bounded by contract so that a hostile or accidentally huge file cannot exhaust
332
- * memory. Writes are expected to be atomic: the default implementation writes to a temporary file
333
- * and renames it into place, so a crash mid-write never leaves a half-written file behind.
334
- * Directory creation is the caller's job, so an implementation whose `writeFile` targets a real
335
- * directory tree must also implement `mkdir`.
388
+ * The fields are: `key`, the entry's identifier within its namespace, non-empty and already flattened
389
+ * to the dotted path an adapter produced; `namespace`, the namespace the entry belongs to, empty when
390
+ * the format has no namespacing; `value`, the translatable string itself, which is untrusted input and
391
+ * may be empty; `description` and `meaning`, both optional free-form context that a provider is told to
392
+ * use only for disambiguation and never to translate or echo back; `placeholders`, the format's
393
+ * placeholder tokens in document order, used for the output integrity check and to decide which entries
394
+ * a placeholder-blind provider must withhold; and `isPlural`, whether the entry carries plural forms
395
+ * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
396
+ * decides and which feeds the content hash.
336
397
  */
337
- interface SdkFs {
338
- /** Reports whether a readable file exists at the path. */
339
- fileExists(path: string): Promise<boolean>;
340
- /** Reads a file as UTF-8 text, refusing to read more than `maxBytes`. */
341
- readFileBounded(path: string, maxBytes: number): Promise<BoundedFileRead>;
342
- /** Reads a file as raw bytes, refusing to read more than `maxBytes`. */
343
- readBytesBounded(path: string, maxBytes: number): Promise<BoundedBytesRead>;
344
- /** Writes UTF-8 text to the path, replacing any existing file atomically. */
345
- writeFile(path: string, data: string): Promise<void>;
346
- /** Writes raw bytes to the path, replacing any existing file atomically. */
347
- writeBytes(path: string, data: Uint8Array): Promise<void>;
398
+ declare const translationEntrySchema$1: z.ZodObject<{
399
+ key: z.ZodString;
400
+ namespace: z.ZodString;
401
+ value: z.ZodString;
402
+ description: z.ZodOptional<z.ZodString>;
403
+ meaning: z.ZodOptional<z.ZodString>;
404
+ placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
405
+ isPlural: z.ZodBoolean;
406
+ }, z.core.$strip>;
407
+ /**
408
+ * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
409
+ * made deeply readonly. See that schema for what each field carries.
410
+ */
411
+ type TranslationEntry$1 = Readonly<z.infer<typeof translationEntrySchema$1>>;
412
+
413
+ /**
414
+ * All translation entries for one locale and namespace, keyed by entry key. This is the neutral
415
+ * intermediate representation every adapter reads into and writes back out of, so nothing downstream
416
+ * of an adapter parses a format's own syntax.
417
+ */
418
+ interface LocaleResource$1 {
419
+ /** The locale these entries belong to (for example, "en" or "de"). */
420
+ readonly locale: string;
421
+ /** The namespace these entries belong to; empty when the format has no namespacing. */
422
+ readonly namespace: string;
423
+ /** The source format the resource came from, retained for round-trip fidelity on write. */
424
+ readonly format: FormatId$1;
425
+ /** Entries addressable by key, in the order the source file defined them. */
426
+ readonly entries: ReadonlyMap<string, TranslationEntry$1>;
427
+ }
428
+
429
+ /** One distinct translated value inside an {@link InconsistencyGroup}, with every key holding it. */
430
+ interface InconsistentTranslation {
348
431
  /**
349
- * Creates a file only if it does not already exist, returning `false` when it does. This is the
350
- * primitive behind the per-locale write lock, so it must be atomic against other processes.
432
+ * The translated value in its compared form: Unicode NFC, line endings folded to `\n`, and
433
+ * leading and trailing whitespace trimmed. Internal whitespace and letter case are kept.
351
434
  */
352
- createExclusive(path: string, data: string): Promise<boolean>;
353
- /** Removes the file at the path, succeeding even when it is already absent. */
354
- deleteFile(path: string): Promise<void>;
355
- /** Creates a directory and any missing parents. Optional; omit it if the backing store has no directories. */
356
- mkdir?(path: string): Promise<void>;
435
+ readonly value: string;
436
+ /** Every key in this locale holding this value, sorted by UTF-16 code unit. */
437
+ readonly keys: readonly string[];
438
+ }
439
+ /**
440
+ * One source string that is translated more than one way within a single target locale. Keys are
441
+ * grouped only when their source value, description, meaning, plural flag, plural form, and context
442
+ * all agree, so a string that deliberately carries different disambiguation metadata is never
443
+ * reported.
444
+ */
445
+ interface InconsistencyGroup {
446
+ /** The shared source value in its compared form (see {@link InconsistentTranslation.value}). */
447
+ readonly source: string;
448
+ /** The disambiguation context every key in the group shares, when the format has one. */
449
+ readonly context?: string;
450
+ /** The description every key in the group shares, when there is one. */
451
+ readonly description?: string;
452
+ /** The meaning every key in the group shares, when there is one. */
453
+ readonly meaning?: string;
454
+ /** Whether the grouped source entries carry plural forms. */
455
+ readonly isPlural: boolean;
456
+ /**
457
+ * The plural form every key in the group shares (a CLDR category such as `one`, or a format's own
458
+ * plural index), when the format encodes one per key. Different forms of one key are never grouped.
459
+ */
460
+ readonly pluralForm?: string;
461
+ /** Every distinct translation, at least two, sorted by value in UTF-16 code unit order. */
462
+ readonly translations: readonly InconsistentTranslation[];
357
463
  }
358
464
 
359
465
  /**
360
- * The on-disk translation memory, read from and written to `verbatra.cache.json` (see
361
- * {@link CACHE_FILE_NAME}). A run consults it before calling the provider, so a string that was
362
- * already translated under the same configuration is reused instead of paid for again.
363
- *
364
- * Entries are keyed by configuration fingerprint, then locale, then source-content hash. The
365
- * fingerprint layer means that changing the provider, model, tone, or glossary does not silently
366
- * reuse translations produced under the old settings; those entries simply stop matching.
466
+ * Result of comparing source placeholders against translated-output placeholders as MULTISETS
467
+ * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
468
+ * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
367
469
  */
368
- interface TranslationMemory {
369
- /** The cache schema version. A file at an unrecognized version is ignored rather than trusted. */
370
- readonly version: number;
371
- /** Cached values, nested as configuration fingerprint, then locale, then source-content hash. */
372
- readonly entries: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, string>>>>>>;
470
+ interface PlaceholderIntegrityResult$1 {
471
+ /** True when nothing is missing or extra: the multisets match, regardless of order. */
472
+ readonly matches: boolean;
473
+ /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
474
+ readonly missing: readonly string[];
475
+ /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
476
+ readonly extra: readonly string[];
477
+ /** Same multiset present in both, but in a different order. Never true alongside a mismatch. */
478
+ readonly reordered: boolean;
373
479
  }
374
480
 
481
+ declare const SOURCE_FRAMEWORKS: readonly ["i18next"];
375
482
  /**
376
- * The file name of the project's translation memory, resolved against the run's working directory.
377
- * Commit it to reuse translations across machines and CI runs, or add it to `.gitignore` to treat
378
- * the cache as purely local. Deleting it is always safe: the next run simply repays for the strings
379
- * it would have reused.
483
+ * The translation framework whose call sites a source scan looks for. The set is closed: a
484
+ * framework is only nameable here once an extractor ships for it, so a configured value that
485
+ * resolves to nothing cannot exist.
486
+ *
487
+ * `i18next` covers the `t(...)`, `$t(...)`, and `<object>.t(...)` shapes, each also through an
488
+ * optional call (`t?.(...)`) and an explicit type-argument list (`t<string>(...)`), with the key as
489
+ * the first argument and an optional default as either the second argument or a `defaultValue`
490
+ * field on the options object. A namespace-qualified key such as `t("common:nav.home")` is
491
+ * reported as a dynamic call site rather than extracted, because one configuration addresses one
492
+ * catalog file.
380
493
  */
381
- declare const CACHE_FILE_NAME = "verbatra.cache.json";
494
+ type SourceFramework = (typeof SOURCE_FRAMEWORKS)[number];
382
495
 
496
+ /** One application source file, already read, as it is handed to a {@link SourceExtractor}. */
497
+ interface SourceFile {
498
+ /** The file's absolute path, used only for reporting. */
499
+ readonly path: string;
500
+ /** The file's full text. It is untrusted input and is never echoed into a result. */
501
+ readonly content: string;
502
+ }
503
+ /** One translation call site whose key argument was a static string. */
504
+ interface ExtractedCallSite {
505
+ /** The key exactly as the call site spelled it. */
506
+ readonly key: string;
507
+ /** The default value the call site supplied, if any. Absent when the call named only a key. */
508
+ readonly defaultValue?: string;
509
+ /** The one-based line the call site sits on. */
510
+ readonly line: number;
511
+ }
383
512
  /**
384
- * The zod schema for the `provider` block, discriminated on `id`. Each variant's options are
385
- * validated strictly, so an option that belongs to a different provider is reported as an error
386
- * rather than ignored. It is embedded in {@link verbatraConfigSchema} and produces
387
- * {@link ProviderConfig}.
513
+ * One translation call site whose key argument could not be resolved statically: a variable, a
514
+ * member expression, or a template literal carrying an expression. It is reported rather than
515
+ * guessed at, so no invented key ever reaches a catalog.
388
516
  */
389
- declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
390
- id: z.ZodLiteral<"anthropic">;
391
- options: z.ZodObject<{
392
- model: z.ZodString;
393
- maxTokens: z.ZodNumber;
394
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
395
- }, z.core.$strict>;
396
- }, z.core.$strip>, z.ZodObject<{
397
- id: z.ZodLiteral<"openai">;
398
- options: z.ZodObject<{
399
- model: z.ZodString;
400
- maxOutputTokens: z.ZodNumber;
401
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
402
- }, z.core.$strict>;
403
- }, z.core.$strip>, z.ZodObject<{
404
- id: z.ZodLiteral<"gemini">;
405
- options: z.ZodObject<{
406
- model: z.ZodString;
407
- maxOutputTokens: z.ZodNumber;
408
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
409
- }, z.core.$strict>;
410
- }, z.core.$strip>, z.ZodObject<{
411
- id: z.ZodLiteral<"deepl">;
412
- options: z.ZodObject<{
413
- glossaryId: z.ZodOptional<z.ZodString>;
414
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
415
- }, z.core.$strict>;
416
- }, z.core.$strip>, z.ZodObject<{
417
- id: z.ZodLiteral<"openai-compatible">;
418
- options: z.ZodObject<{
419
- baseUrl: z.ZodURL;
420
- model: z.ZodString;
421
- maxOutputTokens: z.ZodNumber;
422
- apiKeyEnvVar: z.ZodOptional<z.ZodString>;
423
- requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
424
- }, z.core.$strict>;
425
- }, z.core.$strip>], "id">;
517
+ interface DynamicCallSite {
518
+ /** The one-based line the call site sits on. */
519
+ readonly line: number;
520
+ }
521
+ /** A catalog key a file names statically, wherever it names it. */
522
+ interface ReferencedKeySite {
523
+ /** The key as the source can reach it. */
524
+ readonly key: string;
525
+ /** The one-based line it is named on. */
526
+ readonly line: number;
527
+ }
426
528
  /**
427
- * The `provider` block of a verbatra config: a discriminated union on `id`, so each provider
428
- * carries exactly the options it supports and nothing else.
529
+ * A key argument with a static head and a dynamic rest, such as the template literal `nav.${page}`.
530
+ */
531
+ interface KeyPrefixSite {
532
+ /** The static head every key this site can reach starts with. */
533
+ readonly prefix: string;
534
+ /** The one-based line the site sits on. */
535
+ readonly line: number;
536
+ }
537
+ /**
538
+ * Why a place in the source reaches keys a static reading cannot bound.
539
+ *
540
+ * - `dynamic-key-prefix`: a key prefix (a `keyPrefix` option or a `getFixedT` prefix argument) is
541
+ * not a static string.
542
+ * - `trans-without-key`: a `Trans` element carries no static `i18nKey`, so its key is its children.
543
+ * - `aliased-translate-function`: the translate function is assigned somewhere its calls cannot be
544
+ * followed, such as an object property.
545
+ * - `translate-function-escapes`: a translate function bound from a recognised source appears
546
+ * somewhere other than a direct call, its own binding, a local alias, or the `t` attribute of a
547
+ * `Trans` or `Translation` element (for example as an argument, a property, an export, or a
548
+ * ternary operand), so other code can call it with keys this file never names.
549
+ * - `unrecognised-translate-source`: a translate source (`useTranslation`, `withTranslation`,
550
+ * `getFixedT`, a `Translation` render prop, a member `t`, or a `t` import) is used in a shape the
551
+ * scan does not recognise, so the keys and prefix the resulting function uses are unknown.
552
+ */
553
+ type UnresolvedKeySiteReason = "dynamic-key-prefix" | "trans-without-key" | "aliased-translate-function" | "translate-function-escapes" | "unrecognised-translate-source";
554
+ /** A place whose reachable keys cannot be bounded statically. */
555
+ interface UnresolvedKeySite {
556
+ /** Why the keys it reaches are unknown. */
557
+ readonly reason: UnresolvedKeySiteReason;
558
+ /** The one-based line it sits on. */
559
+ readonly line: number;
560
+ }
561
+ /** Everything one file tells a caller about which catalog keys it can reach. */
562
+ interface KeyUsage {
563
+ /** Every static key the file names, including the extracted calls, in line order. */
564
+ readonly references: readonly ReferencedKeySite[];
565
+ /** Key arguments with no static part at all. */
566
+ readonly dynamic: readonly DynamicCallSite[];
567
+ /** Key arguments with a static head. */
568
+ readonly prefixes: readonly KeyPrefixSite[];
569
+ /** Places whose reachable keys cannot be bounded. */
570
+ readonly unresolved: readonly UnresolvedKeySite[];
571
+ }
572
+ /** Everything one {@link SourceExtractor} found in one file. */
573
+ interface FileExtraction {
574
+ /** Call sites whose key was a static string. */
575
+ readonly calls: readonly ExtractedCallSite[];
576
+ /** Call sites whose key could not be resolved statically. */
577
+ readonly dynamic: readonly DynamicCallSite[];
578
+ /**
579
+ * What the file tells a caller asking which catalog keys the source can reach, beyond the calls
580
+ * above: static keys named some other way, template keys with a static head, and constructs
581
+ * whose keys cannot be bounded. Absent for an extractor that does not report it, in which case
582
+ * the calls and dynamic sites above are all that is known.
583
+ */
584
+ readonly usage?: KeyUsage;
585
+ /**
586
+ * Whether the file could not be read to its end, so everything above it is partial. An
587
+ * unterminated block comment or template literal abandons the rest of the file. In a file read as
588
+ * markup (`.tsx`, `.jsx`, `.js`), so does a JSX element that never closes or is closed by an
589
+ * enclosing element's tag, and a quoted string in code that runs into the end of its line. The
590
+ * scan records it as an `unparseable` diagnostic and carries on, rather than reporting a short
591
+ * result as if it were the whole file.
592
+ */
593
+ readonly truncated?: boolean;
594
+ }
595
+ /**
596
+ * The contract every framework's source extractor implements: given one file's text, report the
597
+ * translation call sites it contains. One implementation per framework, selected by the configured
598
+ * `extract.framework`.
429
599
  *
430
- * No variant has a field for an API key. Keys are read from the environment by the provider itself
431
- * (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`), which is what keeps
432
- * them out of config files and out of version control. The `openai-compatible` variant may name a
433
- * different environment variable through `apiKeyEnvVar`, but still never holds the key itself.
600
+ * It is pure: it reads no file, makes no network call, and holds no state between files. Supplying
601
+ * your own as `deps.createExtractor` is how you teach `extract` a call shape verbatra does not
602
+ * ship yet.
434
603
  */
435
- type ProviderConfig = z.infer<typeof providerConfigSchema>;
604
+ interface SourceExtractor {
605
+ /** The framework whose call shapes this extractor knows. */
606
+ readonly framework: SourceFramework;
607
+ /** The file extensions this extractor can read, each including its leading dot. */
608
+ readonly extensions: readonly string[];
609
+ /**
610
+ * Find every translation call site in one file.
611
+ *
612
+ * @param file - The file's path and full text.
613
+ * @returns The static and dynamic call sites found, in document order.
614
+ */
615
+ extract(file: SourceFile): FileExtraction;
616
+ }
617
+
618
+ /** Where something was found: a source file and a line inside it. Never a file's contents. */
619
+ interface SourceLocation {
620
+ /** The source file, relative to the run's working directory, with forward slashes. */
621
+ readonly file: string;
622
+ /** The one-based line number. */
623
+ readonly line: number;
624
+ }
436
625
  /**
437
- * The identifier of a supported translation provider: `anthropic`, `openai`, `gemini`, `deepl`, or
438
- * `openai-compatible`. The last one targets a local or self-hosted server that speaks the OpenAI
439
- * chat-completions API.
626
+ * One key found at two or more call sites that disagree on its default value. Neither value is
627
+ * written: resolving it last-write-wins would make the catalog depend on directory traversal order,
628
+ * so the disagreement is reported for a person to settle at the call sites.
440
629
  */
441
- type ProviderId = ProviderConfig["id"];
630
+ interface KeyConflict {
631
+ /** The key the call sites disagree about. */
632
+ readonly key: string;
633
+ /** Every distinct default value found, in the order the scan met them. */
634
+ readonly values: readonly string[];
635
+ /** Where each of those values was found, index-aligned with `values`. */
636
+ readonly locations: readonly SourceLocation[];
637
+ }
638
+ type ScanDiagnosticReason = "unreadable" | "too-large" | "unparseable" | "unreadable-directory";
639
+ /** One file or directory the scan could not read, reported as data so one bad file never aborts a run. */
640
+ interface ScanDiagnostic {
641
+ /** The file or directory, relative to the run's working directory, with forward slashes. */
642
+ readonly file: string;
643
+ /** Why it was skipped. */
644
+ readonly reason: ScanDiagnosticReason;
645
+ }
442
646
 
443
647
  /**
444
- * The zod schema every verbatra config is validated against. {@link loadConfig} applies it, so most
445
- * consumers never call it directly; reach for it when you build a config programmatically and want
446
- * to validate it before handing it to {@link translate}, or when you surface config errors in your
447
- * own tooling.
448
- *
449
- * The object is strict, so an unrecognized key is an error rather than being silently ignored: a
450
- * typo in a config file is reported instead of quietly doing nothing. The one exception is the
451
- * optional `$schema` key, accepted so a JSON or YAML config can point an editor at the JSON Schema
452
- * document the package ships as `@verbatra/sdk/config-schema.json`.
453
- *
454
- * Beyond the per-field checks, two whole-config rules are enforced: `targetLocales` must not
455
- * contain the source locale, and it must not contain two locales that differ only in case (they
456
- * would collide on a case-insensitive file system). The `{locale}` token requirement is a
457
- * per-field check on `files.pattern`.
648
+ * One hardcoded string literal that reads as user-facing text and never went through a translation
649
+ * call. It names where the literal sits and a bounded excerpt of it, never the surrounding source.
458
650
  */
459
- declare const verbatraConfigSchema: z.ZodObject<{
460
- $schema: z.ZodOptional<z.ZodString>;
461
- sourceLocale: z.ZodString;
462
- targetLocales: z.ZodArray<z.ZodString>;
463
- format: z.ZodEnum<{
464
- "i18next-json": "i18next-json";
465
- "vue-i18n-json": "vue-i18n-json";
466
- "next-intl-json": "next-intl-json";
467
- "ngx-translate-json": "ngx-translate-json";
468
- xliff: "xliff";
651
+ interface LiteralFinding {
652
+ /** The source file, relative to the run's working directory, with forward slashes. */
653
+ readonly file: string;
654
+ /** The one-based line the literal starts on. */
655
+ readonly line: number;
656
+ /** The one-based column the literal starts at: its opening quote, or its first character of JSX text. */
657
+ readonly column: number;
658
+ /**
659
+ * The literal's text with whitespace collapsed, cut to at most 80 characters. In JSX text and a
660
+ * quoted JSX attribute value, character references such as `&amp;` are decoded.
661
+ */
662
+ readonly text: string;
663
+ /** Whether `text` was cut short. A cut excerpt ends in `...`. */
664
+ readonly truncated: boolean;
665
+ }
666
+ /**
667
+ * Why a literal that would have been reported was held back.
668
+ *
669
+ * - `directive`: a `verbatra-ignore-next-line` comment above it, or a `verbatra-ignore-line`
670
+ * comment on the same line. A next-line directive targets the next line that holds code, skipping
671
+ * blank and comment-only lines, and covers every literal that starts on that line plus every
672
+ * attribute of a JSX opening tag that starts there, even when the tag spans several lines. Text and
673
+ * nested elements on later lines are not covered.
674
+ * - `ignore-list`: its text is listed in `extract.literals.ignore`.
675
+ */
676
+ type LiteralSuppressionReason = "directive" | "ignore-list";
677
+ /** A literal the scan found and did not report, kept so a suppression is visible rather than silent. */
678
+ interface SuppressedLiteral extends LiteralFinding {
679
+ /** Which suppression mechanism held it back. */
680
+ readonly reason: LiteralSuppressionReason;
681
+ }
682
+ /** Everything one untranslated-literal scan found across a project's source. */
683
+ interface LiteralScan {
684
+ /** How many source files were read and scanned. */
685
+ readonly scannedFiles: number;
686
+ /** Literals that look user-facing and were not suppressed, in file then document order. */
687
+ readonly findings: readonly LiteralFinding[];
688
+ /** Literals that were found but suppressed, each with the reason. */
689
+ readonly suppressed: readonly SuppressedLiteral[];
690
+ /**
691
+ * Files and directories the scan could not read to the end. A file listed here was not fully
692
+ * checked, so a scan with any diagnostic is never a clean one.
693
+ */
694
+ readonly diagnostics: readonly ScanDiagnostic[];
695
+ }
696
+
697
+ /**
698
+ * Zod schema accepting exactly one of the fourteen source formats verbatra can read and write. Each
699
+ * member names the ecosystem it round-trips, not just a file extension, because several of them share
700
+ * one: `i18next-json` is i18next's nested JSON with its plural key suffixes, `vue-i18n-json` is Vue
701
+ * I18n's JSON with its pipe-separated plural values, `next-intl-json` is next-intl's ICU-message JSON,
702
+ * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining ten are `xliff` (the XLIFF
703
+ * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle),
704
+ * `properties` (Java and Spring `.properties` files), `apple-strings` (Apple's flat `.strings`
705
+ * localization format for iOS and macOS), `apple-xcstrings` (Apple's Xcode String Catalog, a single
706
+ * JSON document holding every locale of a `.xcstrings` catalogue together), `android-xml` (Android's
707
+ * `res/values/strings.xml` resource format, including `<plurals>` and `translatable="false"` entries),
708
+ * `gettext-po` (GNU gettext's `.po` and `.pot` catalogs, including `msgctxt` disambiguation and
709
+ * `msgid_plural`/`msgstr[n]` plural forms keyed by the file's own `Plural-Forms` index count),
710
+ * `ini` (classic INI configuration files, one level of `[section]` headers over `key=value` lines,
711
+ * whose entries are addressed as `section.key`), and `resx` (.NET's XML resource format; its `<data>`
712
+ * elements carrying a `type` or `mimetype` attribute, and its designer metadata names, are preserved
713
+ * untouched rather than translated).
714
+ *
715
+ * The set is closed: adding a built-in format means adding a member here and an adapter that claims
716
+ * it. A format shipped by a package outside verbatra is named by a `custom:` identifier instead and
717
+ * never joins this set; see `FormatId`.
718
+ */
719
+ declare const supportedFormatSchema: z.ZodEnum<{
720
+ "i18next-json": "i18next-json";
721
+ "vue-i18n-json": "vue-i18n-json";
722
+ "next-intl-json": "next-intl-json";
723
+ "ngx-translate-json": "ngx-translate-json";
724
+ xliff: "xliff";
725
+ yaml: "yaml";
726
+ arb: "arb";
727
+ properties: "properties";
728
+ "apple-strings": "apple-strings";
729
+ "apple-xcstrings": "apple-xcstrings";
730
+ "android-xml": "android-xml";
731
+ "gettext-po": "gettext-po";
732
+ ini: "ini";
733
+ resx: "resx";
734
+ }>;
735
+ /** One of the fourteen supported source formats; the inferred type of {@link supportedFormatSchema}. */
736
+ type SupportedFormat = z.infer<typeof supportedFormatSchema>;
737
+ /**
738
+ * A format identifier claimed by an adapter that ships outside verbatra: the reserved
739
+ * `custom:` prefix followed by a lowercase, hyphen-separated name, for example
740
+ * `custom:my-format`.
741
+ *
742
+ * No built-in format name contains a colon, so a third-party identifier can never shadow a
743
+ * built-in one.
744
+ */
745
+ type CustomFormatId = `custom:${string}`;
746
+ /**
747
+ * Any format verbatra can be asked to handle: one of the built-in {@link SupportedFormat}
748
+ * members, or a third-party {@link CustomFormatId}.
749
+ *
750
+ * A built-in name is checked against the closed set at compile time. A `custom:` identifier is
751
+ * only checked for shape, because the adapter behind it lives outside this package; if no
752
+ * registered adapter claims it, the run fails with a structured error naming the format.
753
+ */
754
+ type FormatId = SupportedFormat | CustomFormatId;
755
+
756
+ /**
757
+ * A single, format-neutral translation unit. Placeholders are supplied already extracted; core never
758
+ * derives them from the value, so an adapter that reads a file is the one authority on what counts as
759
+ * a placeholder in that format.
760
+ *
761
+ * The fields are: `key`, the entry's identifier within its namespace, non-empty and already flattened
762
+ * to the dotted path an adapter produced; `namespace`, the namespace the entry belongs to, empty when
763
+ * the format has no namespacing; `value`, the translatable string itself, which is untrusted input and
764
+ * may be empty; `description` and `meaning`, both optional free-form context that a provider is told to
765
+ * use only for disambiguation and never to translate or echo back; `placeholders`, the format's
766
+ * placeholder tokens in document order, used for the output integrity check and to decide which entries
767
+ * a placeholder-blind provider must withhold; and `isPlural`, whether the entry carries plural forms
768
+ * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
769
+ * decides and which feeds the content hash.
770
+ */
771
+ declare const translationEntrySchema: z.ZodObject<{
772
+ key: z.ZodString;
773
+ namespace: z.ZodString;
774
+ value: z.ZodString;
775
+ description: z.ZodOptional<z.ZodString>;
776
+ meaning: z.ZodOptional<z.ZodString>;
777
+ placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
778
+ isPlural: z.ZodBoolean;
779
+ }, z.core.$strip>;
780
+ /**
781
+ * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
782
+ * made deeply readonly. See that schema for what each field carries.
783
+ */
784
+ type TranslationEntry = Readonly<z.infer<typeof translationEntrySchema>>;
785
+
786
+ /**
787
+ * All translation entries for one locale and namespace, keyed by entry key. This is the neutral
788
+ * intermediate representation every adapter reads into and writes back out of, so nothing downstream
789
+ * of an adapter parses a format's own syntax.
790
+ */
791
+ interface LocaleResource {
792
+ /** The locale these entries belong to (for example, "en" or "de"). */
793
+ readonly locale: string;
794
+ /** The namespace these entries belong to; empty when the format has no namespacing. */
795
+ readonly namespace: string;
796
+ /** The source format the resource came from, retained for round-trip fidelity on write. */
797
+ readonly format: FormatId;
798
+ /** Entries addressable by key, in the order the source file defined them. */
799
+ readonly entries: ReadonlyMap<string, TranslationEntry>;
800
+ }
801
+
802
+ /**
803
+ * Result of comparing source placeholders against translated-output placeholders as MULTISETS
804
+ * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
805
+ * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
806
+ */
807
+ interface PlaceholderIntegrityResult {
808
+ /** True when nothing is missing or extra: the multisets match, regardless of order. */
809
+ readonly matches: boolean;
810
+ /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
811
+ readonly missing: readonly string[];
812
+ /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
813
+ readonly extra: readonly string[];
814
+ /** Same multiset present in both, but in a different order. Never true alongside a mismatch. */
815
+ readonly reordered: boolean;
816
+ }
817
+
818
+ /**
819
+ * The result of reading a file into core's intermediate representation. The two diagnostic lists are
820
+ * reported as data rather than thrown, so one bad leaf never fails a whole read.
821
+ */
822
+ interface ReadResult {
823
+ /** The parsed locale resource. */
824
+ readonly resource: LocaleResource;
825
+ /**
826
+ * Keys whose values are invalid for the format's message syntax. Empty for a format with no
827
+ * message-syntax check, which treats every value as valid.
828
+ */
829
+ readonly invalidIcuKeys: readonly string[];
830
+ /**
831
+ * Keys or dotted paths that were present in the source but excluded from translation: a stray
832
+ * non-string leaf (a number, boolean, or null) for a tree-based format, or a key the format's own
833
+ * metadata marks as not translatable, such as an Android `translatable="false"` resource. These
834
+ * are never translated, hashed, diffed, or checked for placeholder or ICU integrity.
835
+ *
836
+ * Reporting them is the adapter's choice, so an empty list means this adapter reported none, not
837
+ * necessarily that the file carried none. A tree-based adapter reports its non-string leaves
838
+ * automatically; a flat adapter reports by returning `{ entries, excludedLeafPaths }` from
839
+ * `parseEntries` instead of a bare map.
840
+ *
841
+ * Whether one survives a later rewrite of the same file depends on the adapter: an adapter that
842
+ * reconstructs the whole file from `resource.entries` alone drops it, while an adapter that
843
+ * patches only the entries it touches into the existing document (for example `apple-xcstrings`)
844
+ * preserves it verbatim.
845
+ */
846
+ readonly excludedLeafPaths: readonly string[];
847
+ }
848
+ /**
849
+ * The contract every format adapter implements. A new format attaches by implementing this interface
850
+ * and registering it in an {@link AdapterRegistry}.
851
+ *
852
+ * Implement it through one of the two shared factories rather than from scratch:
853
+ * `createTreeFileAdapter` for a nested-tree format and `createFlatFileAdapter` for a flat
854
+ * key/value format. A format that fits neither shape implements this interface directly.
855
+ *
856
+ * An adapter shipped outside verbatra names itself with a `custom:` identifier and is attached by
857
+ * registering it in a registry the caller hands to the SDK. It is ordinary trusted code: verbatra
858
+ * does not sandbox it.
859
+ */
860
+ interface FormatAdapter {
861
+ /**
862
+ * The single format this adapter handles: one of core's built-in `SupportedFormat` members, or a
863
+ * third-party `custom:` identifier for an adapter that ships outside verbatra.
864
+ */
865
+ readonly format: FormatId;
866
+ /**
867
+ * Detect whether this adapter can handle a file, by path extension and an optional content sample.
868
+ * Best-effort and side-effect-free; it reads nothing from disk.
869
+ *
870
+ * @param filePath - The path of the file under consideration.
871
+ * @param sample - An optional leading content sample to aid detection.
872
+ * @returns True if this adapter could handle the file. Several adapters may return true for the same
873
+ * file (all JSON adapters claim `.json`); the caller disambiguates by explicit format.
874
+ */
875
+ canHandle(filePath: string, sample?: string): boolean;
876
+ /**
877
+ * Read a file into a {@link LocaleResource} plus its message-validity result.
878
+ *
879
+ * @param filePath - The file to read.
880
+ * @param locale - The locale to tag the resource with.
881
+ * @returns The parsed resource and the keys whose values are invalid for the format.
882
+ * @throws `AdapterError` when the content is malformed, oversized, or structurally invalid
883
+ * (the implementation names the specific codes). A missing or unopenable path instead rejects with
884
+ * the underlying filesystem error.
885
+ */
886
+ read(filePath: string, locale: string): Promise<ReadResult>;
887
+ /**
888
+ * Write a {@link LocaleResource} back to a file, preserving key order and structure, atomically.
889
+ *
890
+ * @param resource - The resource to serialize.
891
+ * @param filePath - The destination file.
892
+ * @throws `AdapterError` if the resource cannot be represented in the format; rejects with the
893
+ * underlying filesystem error on a write failure.
894
+ */
895
+ write(resource: LocaleResource, filePath: string): Promise<void>;
896
+ /**
897
+ * Extract the format's placeholder tokens from a single value, resolving nothing.
898
+ *
899
+ * @param value - The translatable string to scan.
900
+ * @returns The placeholder tokens found, in document order. Does not throw.
901
+ */
902
+ extractPlaceholders(value: string): readonly string[];
903
+ /**
904
+ * Validate a single value against the format's message syntax before it is written. Only the
905
+ * ICU-message formats carry a real check today (next-intl and ARB); every other adapter has no
906
+ * message syntax to violate and returns true for any value.
907
+ *
908
+ * @param value - The candidate translated value to validate.
909
+ * @returns True when the value is valid for the format's message syntax. Does not throw; an
910
+ * unparseable value returns false.
911
+ */
912
+ validateMessage(value: string): boolean;
913
+ /**
914
+ * Optional whole-value placeholder comparison, used by callers instead of independently extracting
915
+ * each side's placeholders with {@link extractPlaceholders} and diffing the flat lists. Both
916
+ * adapter factories accept one, under the `comparePlaceholders` option. Among the shipped
917
+ * adapters two families define one, for different reasons:
918
+ *
919
+ * - The ICU formats (next-intl and ARB) compare branch by branch, because flattening a
920
+ * plural/select value loses which branch a placeholder came from.
921
+ * - The double-brace formats (i18next, ngx-translate, and YAML) add a one-directional
922
+ * single-brace check on top of their flat multiset comparison: a `{name}`-shaped token present
923
+ * in the translated value but absent from the source is reported as `extra`. Single-brace text
924
+ * is ordinary literal content under these formats' default delimiters, so it is never required
925
+ * to survive a translation, but inventing one is never a legitimate translation either.
926
+ *
927
+ * Absent for the remaining adapters, which are compared via `extractPlaceholders` plus a flat
928
+ * multiset check.
929
+ *
930
+ * @param sourceValue - The source value.
931
+ * @param targetValue - The translated value to check against it.
932
+ * @returns The merged placeholder-integrity result. Does not throw.
933
+ */
934
+ comparePlaceholders?(sourceValue: string, targetValue: string): PlaceholderIntegrityResult;
935
+ }
936
+
937
+ /**
938
+ * The outcome of a size-bounded read through the {@link AdapterFs} port. A path that is not a file,
939
+ * or a file larger than the caller's limit, is reported as a state rather than thrown, so an adapter
940
+ * can treat "no file yet" and "too large to trust" as ordinary branches.
941
+ */
942
+ type BoundedReadOutcome = {
943
+ readonly kind: "ok";
944
+ readonly content: string;
945
+ } | {
946
+ readonly kind: "not-a-file";
947
+ } | {
948
+ readonly kind: "too-large";
949
+ };
950
+ /**
951
+ * The file-system port every format adapter reads and writes through. An adapter that takes this
952
+ * port instead of importing `node:fs` can be tested against an in-memory implementation and stays
953
+ * honest about every path it touches.
954
+ *
955
+ * This is the supported path for an adapter shipped outside verbatra, and the one verbatra hands it.
956
+ * It is a convention, not a sandbox: nothing stops third-party code from reaching the file system
957
+ * another way.
958
+ */
959
+ interface AdapterFs {
960
+ /**
961
+ * Read a UTF-8 text file, refusing anything larger than the caller's limit.
962
+ *
963
+ * @param path - The file to read.
964
+ * @param maxBytes - The largest file size to read; a larger file yields `too-large` and is not read.
965
+ * @returns The file's content, or the state that prevented reading it.
966
+ */
967
+ readBounded(path: string, maxBytes: number): Promise<BoundedReadOutcome>;
968
+ /**
969
+ * Write a UTF-8 text file atomically, creating missing parent directories.
970
+ *
971
+ * @param path - The destination file.
972
+ * @param data - The content to write.
973
+ * @returns Nothing, once the content is durably in place under `path`.
974
+ */
975
+ writeFileAtomic(path: string, data: string): Promise<void>;
976
+ }
977
+ /**
978
+ * The {@link AdapterFs} implementation backed by the real file system, and the default every adapter
979
+ * factory uses when no port is supplied. It writes through a temporary file and a rename, so an
980
+ * interrupted write never leaves a half-finished locale file.
981
+ *
982
+ * @example
983
+ * ```ts
984
+ * const adapter = createFlatFileAdapter({ ...options, fs: nodeAdapterFs });
985
+ * ```
986
+ */
987
+ declare const nodeAdapterFs: AdapterFs;
988
+
989
+ /**
990
+ * Outcome of resolving an adapter for a file. Structured, never thrown: an unresolvable file is a
991
+ * status the caller branches on, not an exception.
992
+ *
993
+ * `resolved` carries the single {@link FormatAdapter} to use. `no-match` means nothing claimed the
994
+ * file, and reports the formats that were tried (every registered format for a detection attempt, or
995
+ * just the one requested when an explicit format was given). `ambiguous` means more than one adapter
996
+ * claimed the file, which is routine since every JSON adapter claims `.json`; it reports the competing
997
+ * candidates instead of guessing, and the caller resolves it by naming a format explicitly.
998
+ */
999
+ type AdapterResolution = {
1000
+ /** Exactly one adapter claimed the file. */
1001
+ readonly status: "resolved";
1002
+ /** The adapter to read and write the file with. */
1003
+ readonly adapter: FormatAdapter;
1004
+ } | {
1005
+ /** No registered adapter claimed the file. */
1006
+ readonly status: "no-match";
1007
+ /** The file that could not be matched. */
1008
+ readonly filePath: string;
1009
+ /**
1010
+ * The formats that were tried: every registered format for a detection attempt, or just the
1011
+ * one requested when an explicit format was given.
1012
+ */
1013
+ readonly triedFormats: readonly FormatId[];
1014
+ } | {
1015
+ /** More than one adapter claimed the file, so detection cannot choose between them. */
1016
+ readonly status: "ambiguous";
1017
+ /** The file that several adapters claimed. */
1018
+ readonly filePath: string;
1019
+ /** The competing formats. Resolve the ambiguity by naming one of them explicitly. */
1020
+ readonly candidates: readonly FormatId[];
1021
+ };
1022
+ /** Options for {@link AdapterRegistry.resolve}: select a format explicitly, or aid detection. */
1023
+ interface ResolveOptions {
1024
+ /** A leading content sample to aid detection. Ignored when `format` is given. */
1025
+ readonly sample?: string;
1026
+ /** Bypass detection and select this format explicitly. */
1027
+ readonly format?: FormatId;
1028
+ }
1029
+ /**
1030
+ * Holds the registered adapters and resolves one for a file. Open for extension: adapters attach
1031
+ * through {@link AdapterRegistry.register} without changing resolution logic, and an unresolvable
1032
+ * file is a structured status rather than an exception.
1033
+ *
1034
+ * @example
1035
+ * ```ts
1036
+ * const registry = createDefaultRegistry().register(createTomlAdapter());
1037
+ * const resolution = registry.resolve("locales/de.toml");
1038
+ * if (resolution.status === "resolved") {
1039
+ * const { resource } = await resolution.adapter.read("locales/de.toml", "de");
1040
+ * }
1041
+ * ```
1042
+ */
1043
+ declare class AdapterRegistry {
1044
+ private readonly adapters;
1045
+ /**
1046
+ * Register an adapter. Registration order is the order detection consults them.
1047
+ *
1048
+ * A format identifier may be registered only once: a second adapter claiming an identifier the
1049
+ * registry already holds is rejected, so neither a third-party adapter nor a second copy of one
1050
+ * can silently shadow what is already there.
1051
+ *
1052
+ * An adapter whose format is a third-party `custom:` identifier is wrapped so that an unexpected
1053
+ * throw from any of its contract methods surfaces as an {@link AdapterError} naming the format.
1054
+ * Built-in adapters are registered as they are.
1055
+ *
1056
+ * @param adapter - The adapter to add.
1057
+ * @returns This registry, for chaining.
1058
+ * @throws `AdapterError` with code `INVALID_FORMAT_ID` when the format carries the `custom:`
1059
+ * prefix but is not a well-formed identifier. Such an adapter is refused rather than registered
1060
+ * without the containment wrapper, which would silently lose its failure attribution.
1061
+ * @throws `AdapterError` with code `DUPLICATE_FORMAT` when the registry already holds an adapter
1062
+ * for this format.
1063
+ *
1064
+ * @example
1065
+ * ```ts
1066
+ * const registry = createDefaultRegistry().register(createTomlAdapter());
1067
+ * await translate({ config }, { adapterRegistry: registry });
1068
+ * ```
1069
+ */
1070
+ register(adapter: FormatAdapter): this;
1071
+ private formats;
1072
+ private resolveByFormat;
1073
+ private resolveByDetection;
1074
+ /**
1075
+ * Resolve the adapter for a file, by explicit format when given, otherwise by detection.
1076
+ *
1077
+ * @param filePath - The file to resolve an adapter for.
1078
+ * @param options - `format` selects explicitly and skips detection; `sample` aids detection.
1079
+ * @returns A structured {@link AdapterResolution}: `resolved`, `no-match`, or `ambiguous`. An
1080
+ * unresolvable file is a status, not an exception.
1081
+ * @throws `AdapterError` with code `ADAPTER_FAILED` when a registered third-party adapter throws
1082
+ * from its own `canHandle` during detection. Resolution itself raises nothing.
1083
+ */
1084
+ resolve(filePath: string, options?: ResolveOptions): AdapterResolution;
1085
+ }
1086
+
1087
+ /**
1088
+ * Build a registry holding every format adapter verbatra ships, in detection order. This is the
1089
+ * starting point for adding an adapter of your own: register it on the returned registry and hand
1090
+ * that registry to an SDK flow as its `adapterRegistry` dependency.
1091
+ *
1092
+ * @param fs - The file-system port every built-in adapter reads and writes through. Defaults to
1093
+ * {@link nodeAdapterFs}.
1094
+ * @returns A registry of the built-in adapters, ready for further registrations.
1095
+ *
1096
+ * @example
1097
+ * ```ts
1098
+ * const registry = createDefaultRegistry().register(createTomlAdapter());
1099
+ * await translate({ config }, { adapterRegistry: registry });
1100
+ * ```
1101
+ */
1102
+ declare function createDefaultRegistry(fs?: AdapterFs): AdapterRegistry;
1103
+
1104
+ /**
1105
+ * The stable, secret-free codes an {@link AdapterError} carries.
1106
+ *
1107
+ * The first seven describe a file verbatra could not handle: `INVALID_JSON`, `INVALID_YAML` and
1108
+ * `INVALID_XML` for malformed syntax, `INVALID_STRUCTURE` for a parseable file of the wrong shape,
1109
+ * `MAX_DEPTH_EXCEEDED` and `INPUT_TOO_LARGE` for the read caps, and `MIXED_STRUCTURE` for a file
1110
+ * that mixes flat and nested keys where the format forbids it.
1111
+ *
1112
+ * The last three describe an adapter rather than a file. `ADAPTER_FAILED` attributes an unexpected
1113
+ * throw to the third-party adapter it came from, so a plugin defect never surfaces as an
1114
+ * unattributed verbatra stack trace. `DUPLICATE_FORMAT` rejects registering a second adapter for a
1115
+ * format identifier a registry already holds. `INVALID_FORMAT_ID` rejects registering an adapter
1116
+ * whose `custom:` identifier is malformed, which would otherwise be registered without the
1117
+ * containment wrapper and silently lose its failure attribution.
1118
+ *
1119
+ * The set is closed. A third-party adapter raises `INVALID_STRUCTURE` (or another member that fits)
1120
+ * rather than minting its own code.
1121
+ */
1122
+ type AdapterErrorCode = "INVALID_JSON" | "INVALID_YAML" | "INVALID_XML" | "INVALID_STRUCTURE" | "MAX_DEPTH_EXCEEDED" | "INPUT_TOO_LARGE" | "MIXED_STRUCTURE" | "ADAPTER_FAILED" | "DUPLICATE_FORMAT" | "INVALID_FORMAT_ID";
1123
+ /**
1124
+ * A structured, secret-free failure raised by a format adapter or by the adapter registry. Catch it
1125
+ * to branch on {@link AdapterErrorCode} instead of matching on message text.
1126
+ *
1127
+ * @example
1128
+ * ```ts
1129
+ * try {
1130
+ * await adapter.read("locales/de.json", "de");
1131
+ * } catch (error) {
1132
+ * if (error instanceof AdapterError && error.code === "INVALID_JSON") {
1133
+ * console.error(error.message);
1134
+ * }
1135
+ * }
1136
+ * ```
1137
+ */
1138
+ declare class AdapterError extends Error {
1139
+ /** The stable code for this failure. */
1140
+ readonly code: AdapterErrorCode;
1141
+ /**
1142
+ * @param code - The stable code for this failure.
1143
+ * @param message - A human-readable description. Never include a secret or a file's contents.
1144
+ */
1145
+ constructor(code: AdapterErrorCode, message: string);
1146
+ }
1147
+
1148
+ /** A value in a locale tree about to be serialized, with key order preserved at every level. */
1149
+ type OrderedValue = string | number | boolean | null | OrderedRecord | readonly OrderedValue[];
1150
+ /** A locale tree about to be serialized, keyed in the order it will be written out. */
1151
+ type OrderedRecord = ReadonlyMap<string, OrderedValue>;
1152
+
1153
+ /** A terminal value in a parsed locale tree: everything that is not a nested object. */
1154
+ type JsonLeaf = string | number | boolean | null;
1155
+ /** Any node in a parsed locale tree: a leaf, or a nested record of further nodes. */
1156
+ type JsonTree = JsonLeaf | JsonRecord;
1157
+ /** A parsed locale tree node, keyed in the order the source document defined its keys. */
1158
+ type JsonRecord = ReadonlyMap<string, JsonTree>;
1159
+
1160
+ /**
1161
+ * Finds a format's placeholder tokens in one translatable value, in document order. Resolves
1162
+ * nothing and never throws; a value with no placeholders yields an empty list.
1163
+ */
1164
+ type ExtractPlaceholders = (value: string) => readonly string[];
1165
+ /**
1166
+ * Reports the keys whose values are invalid for a format's message syntax, given every entry read
1167
+ * from one file. Only the ICU-message formats need one; a format with no message syntax omits it.
1168
+ */
1169
+ type ComputeInvalidIcuKeys = (entries: ReadonlyMap<string, TranslationEntry>) => readonly string[];
1170
+ /**
1171
+ * Decides whether one candidate value is valid for a format's message syntax before it is written.
1172
+ * Never throws: an unparseable value returns false.
1173
+ */
1174
+ type ValidateMessage = (value: string) => boolean;
1175
+ /**
1176
+ * Compares a source value against its translation as whole values rather than as two flat token
1177
+ * lists, for a format whose structure (an ICU plural or select branch, for example) would be lost by
1178
+ * flattening. Never throws: a mismatch is data.
1179
+ */
1180
+ type ComparePlaceholders = (sourceValue: string, targetValue: string) => PlaceholderIntegrityResult;
1181
+ /**
1182
+ * Rejects a parsed tree that is structurally wrong for a format, by throwing an `AdapterError`.
1183
+ * Returns nothing when the tree is acceptable.
1184
+ */
1185
+ type ValidateTree = (tree: JsonRecord) => void;
1186
+ /**
1187
+ * Decides whether a leading content sample looks like this format, so two adapters claiming the same
1188
+ * file extension can still be told apart. Consulted only when a sample is available.
1189
+ */
1190
+ type Sniff = (sample: string) => boolean;
1191
+
1192
+ /**
1193
+ * What a flat format's `parseEntries` returns when it has content to report as skipped. Returning a
1194
+ * bare map instead is equivalent to returning one of these with no skipped paths.
1195
+ */
1196
+ interface FlatParseResult {
1197
+ /** The entries read from the file, keyed by entry key, in document order. */
1198
+ readonly entries: Map<string, TranslationEntry>;
1199
+ /**
1200
+ * Keys the file carried but that are excluded from translation, for example one the format's own
1201
+ * metadata marks as not translatable. Omit it when the format has no such concept.
1202
+ */
1203
+ readonly excludedLeafPaths?: readonly string[];
1204
+ }
1205
+ /** What a flat format's `parseEntries` may return: the entries alone, or entries plus diagnostics. */
1206
+ type FlatParseOutcome = Map<string, TranslationEntry> | FlatParseResult;
1207
+ /** Everything {@link createFlatFileAdapter} needs to turn a flat key/value format into an adapter. */
1208
+ interface FlatFileAdapterOptions {
1209
+ /** The format this adapter claims: a built-in name, or a `custom:` identifier of your own. */
1210
+ readonly format: FormatId;
1211
+ /** The lowercase file extensions this format owns, each including the leading dot. */
1212
+ readonly extensions: readonly string[];
1213
+ /**
1214
+ * Optional content check, consulted when a sample is available, so this adapter does not claim
1215
+ * every file that merely shares one of its extensions.
1216
+ */
1217
+ readonly sniff?: Sniff;
1218
+ /**
1219
+ * Parse one file's text into entries keyed by entry key, in document order. Throw an
1220
+ * `AdapterError` for content this format cannot represent; anything else thrown is reported as a
1221
+ * structural failure.
1222
+ */
1223
+ readonly parseEntries: (content: string, namespace: string, filePath: string, fs: AdapterFs) => FlatParseOutcome | Promise<FlatParseOutcome>;
1224
+ /**
1225
+ * Render entries back to the format's text, preserving key order. Receives the destination path
1226
+ * and the port, for a format that has to consult the existing file to write in place.
1227
+ */
1228
+ readonly serializeEntries: (entries: ReadonlyMap<string, TranslationEntry>, filePath: string, fs: AdapterFs) => Promise<string> | string;
1229
+ /** Find this format's placeholder tokens in one value. */
1230
+ readonly extractPlaceholders: ExtractPlaceholders;
1231
+ /** Optional message-syntax check applied to a candidate value before it is written. */
1232
+ readonly validateMessage?: ValidateMessage;
1233
+ /** Optional per-file report of the keys whose values are invalid for this format's message syntax. */
1234
+ readonly computeInvalidIcuKeys?: ComputeInvalidIcuKeys;
1235
+ /**
1236
+ * Optional whole-value placeholder comparison, for a format whose structure a flat token list
1237
+ * would lose. Omitted adapters are compared by `extractPlaceholders` plus a flat multiset check.
1238
+ */
1239
+ readonly comparePlaceholders?: ComparePlaceholders;
1240
+ /** The file-system port to read and write through. Defaults to `nodeAdapterFs`. */
1241
+ readonly fs?: AdapterFs;
1242
+ }
1243
+ /**
1244
+ * Build a {@link FormatAdapter} for a flat key/value format: one where every entry is addressed by a
1245
+ * single key with no nesting, such as Java `.properties` or Android `strings.xml`. The factory
1246
+ * supplies the bounded read, the atomic write, extension detection and the structured error
1247
+ * handling; you supply only the format's own parsing and serialization.
1248
+ *
1249
+ * @param options - The format's parsing, serialization and detection behaviour.
1250
+ * @returns A complete adapter, ready to register on an `AdapterRegistry`.
1251
+ * @throws Nothing itself. The returned adapter's `read` and `write` raise `AdapterError` for content
1252
+ * this format cannot represent, and reject with the underlying filesystem error otherwise.
1253
+ *
1254
+ * @example
1255
+ * ```ts
1256
+ * const tomlAdapter = createFlatFileAdapter({
1257
+ * format: "custom:toml",
1258
+ * extensions: [".toml"],
1259
+ * parseEntries: (content, namespace) => parseToml(content, namespace),
1260
+ * serializeEntries: (entries) => serializeToml(entries),
1261
+ * extractPlaceholders: (value) => [...value.matchAll(/\{\w+\}/g)].map((m) => m[0]),
1262
+ * });
1263
+ * ```
1264
+ */
1265
+ declare function createFlatFileAdapter(options: FlatFileAdapterOptions): FormatAdapter;
1266
+
1267
+ /**
1268
+ * Derives the per-entry facts a format decides for itself from one key and its string value: the
1269
+ * placeholder tokens the value carries, and whether the key is one plural form of a set.
1270
+ */
1271
+ type DeriveEntry = (key: string, value: string) => {
1272
+ readonly placeholders: readonly string[];
1273
+ readonly isPlural: boolean;
1274
+ };
1275
+ /**
1276
+ * How a dotted key in the source document is addressed. `literal-leaf` keeps `"a.b"` as one key
1277
+ * whose name contains a dot; `path-notation` reads it as the nested path `a` then `b`.
1278
+ */
1279
+ type KeyMode = "literal-leaf" | "path-notation";
1280
+
1281
+ /**
1282
+ * Builds the tree to serialize from the entries about to be written, for a format that patches an
1283
+ * existing document rather than rebuilding it from the entries alone.
1284
+ */
1285
+ type BuildWriteTree = (entries: ReadonlyMap<string, TranslationEntry>, filePath: string, fs: AdapterFs) => OrderedRecord | Promise<OrderedRecord>;
1286
+ /**
1287
+ * Pulls per-key translator descriptions out of the raw file text, for a format that carries them as
1288
+ * metadata alongside the values.
1289
+ */
1290
+ type DeriveDescriptions = (content: string) => ReadonlyMap<string, string>;
1291
+ /** Everything {@link createTreeFileAdapter} needs to turn a nested-tree format into an adapter. */
1292
+ interface TreeFileAdapterOptions {
1293
+ /** The format this adapter claims: a built-in name, or a `custom:` identifier of your own. */
1294
+ readonly format: FormatId;
1295
+ /** The lowercase file extensions this format owns, each including the leading dot. */
1296
+ readonly extensions: readonly string[];
1297
+ /**
1298
+ * Optional content check, consulted when a sample is available, so this adapter does not claim
1299
+ * every file that merely shares one of its extensions.
1300
+ */
1301
+ readonly sniff?: Sniff;
1302
+ /** Parse one file's text into a nested tree, preserving key order at every level. */
1303
+ readonly parse: (content: string) => JsonRecord;
1304
+ /** Render a nested tree back to the format's text, preserving key order at every level. */
1305
+ readonly serialize: (tree: OrderedRecord) => string;
1306
+ /** Derive the placeholder tokens and the plural flag for one leaf. */
1307
+ readonly deriveEntry: DeriveEntry;
1308
+ /** Find this format's placeholder tokens in one value. */
1309
+ readonly extractPlaceholders: ExtractPlaceholders;
1310
+ /** Optional per-file report of the keys whose values are invalid for this format's message syntax. */
1311
+ readonly computeInvalidIcuKeys?: ComputeInvalidIcuKeys;
1312
+ /** Optional message-syntax check applied to a candidate value before it is written. */
1313
+ readonly validateMessage?: ValidateMessage;
1314
+ /** Optional structural check on the parsed tree, throwing an `AdapterError` for a shape this format forbids. */
1315
+ readonly validateTree?: ValidateTree;
1316
+ /** Optional override for how the tree to write is assembled, for a format that patches the existing document. */
1317
+ readonly buildWriteTree?: BuildWriteTree;
1318
+ /** How a dotted key is addressed. Defaults to `"literal-leaf"`. */
1319
+ readonly keyMode?: KeyMode;
1320
+ /** Optional whole-value placeholder comparison, for a format whose structure flattening would lose. */
1321
+ readonly comparePlaceholders?: ComparePlaceholders;
1322
+ /** Optional extraction of per-key translator descriptions from the raw file text. */
1323
+ readonly deriveDescriptions?: DeriveDescriptions;
1324
+ /** The file-system port to read and write through. Defaults to `nodeAdapterFs`. */
1325
+ readonly fs?: AdapterFs;
1326
+ }
1327
+ /**
1328
+ * Build a {@link FormatAdapter} for a nested-tree format: one whose entries live at paths through
1329
+ * nested objects, such as i18next JSON or YAML. The factory supplies the bounded read, the atomic
1330
+ * write, tree flattening and unflattening, extension detection and the structured error handling;
1331
+ * you supply only the format's own parsing, serialization and per-leaf facts.
1332
+ *
1333
+ * @param options - The format's parsing, serialization, detection and per-entry behaviour.
1334
+ * @returns A complete adapter, ready to register on an `AdapterRegistry`.
1335
+ * @throws Nothing itself. The returned adapter's `read` and `write` raise `AdapterError` for content
1336
+ * this format cannot represent, and reject with the underlying filesystem error otherwise.
1337
+ *
1338
+ * @example
1339
+ * ```ts
1340
+ * const hoconAdapter = createTreeFileAdapter({
1341
+ * format: "custom:hocon",
1342
+ * extensions: [".conf"],
1343
+ * parse: (content) => parseHocon(content),
1344
+ * serialize: (tree) => serializeHocon(tree),
1345
+ * deriveEntry: (_key, value) => ({ placeholders: tokensIn(value), isPlural: false }),
1346
+ * extractPlaceholders: (value) => tokensIn(value),
1347
+ * });
1348
+ * ```
1349
+ */
1350
+ declare function createTreeFileAdapter(options: TreeFileAdapterOptions): FormatAdapter;
1351
+
1352
+ /**
1353
+ * The outcome of a size-bounded text read. A file that is absent or larger than the caller's limit
1354
+ * is reported as a state rather than thrown, so a caller can treat "no file yet" as normal and an
1355
+ * oversized file as a distinct, actionable condition.
1356
+ */
1357
+ type BoundedFileRead = {
1358
+ /** The file was read in full. */
1359
+ readonly kind: "ok";
1360
+ /** The file's content, decoded as UTF-8. */
1361
+ readonly content: string;
1362
+ } | {
1363
+ /** No readable file exists at the path. */
1364
+ readonly kind: "missing";
1365
+ } | {
1366
+ /** The file exists but exceeds the requested byte limit, so nothing was read. */
1367
+ readonly kind: "too-large";
1368
+ };
1369
+ /**
1370
+ * The outcome of a size-bounded binary read, used for interchange workbooks. Mirrors
1371
+ * {@link BoundedFileRead} but yields raw bytes rather than decoded text.
1372
+ */
1373
+ type BoundedBytesRead = {
1374
+ /** The file was read in full. */
1375
+ readonly kind: "ok";
1376
+ /** The file's raw bytes. */
1377
+ readonly bytes: Uint8Array;
1378
+ } | {
1379
+ /** No readable file exists at the path. */
1380
+ readonly kind: "missing";
1381
+ } | {
1382
+ /** The file exists but exceeds the requested byte limit, so nothing was read. */
1383
+ readonly kind: "too-large";
1384
+ };
1385
+ /**
1386
+ * What one entry in a directory listing is: a regular file, a directory, or anything else. A
1387
+ * symbolic link is reported as `other`, never followed, which is what keeps a source scan inside
1388
+ * the roots it was given.
1389
+ */
1390
+ interface DirectoryEntry {
1391
+ /** The entry's own name, with no directory part. */
1392
+ readonly name: string;
1393
+ /** What the entry is. Anything that is not a regular file or a directory is `other`. */
1394
+ readonly kind: "file" | "directory" | "other";
1395
+ }
1396
+ /**
1397
+ * The file-system port the SDK's own I/O goes through. Supplying your own implementation as a
1398
+ * `deps.fs` option redirects that I/O, which is how the SDK's tests avoid touching disk and how an
1399
+ * embedding application can back part of a project with something other than a local disk.
1400
+ *
1401
+ * The seam carries every file the SDK touches: the run-status file, the lock-file, the config
1402
+ * glossary, workbook and interchange I/O, and the locale files themselves, which the adapters read
1403
+ * and write through a port built from this one. The single exception is a caller-supplied
1404
+ * `deps.adapterRegistry`: those adapters were constructed by the caller, so their file access is
1405
+ * whatever the caller wired into them, and supplying both means the caller owns that wiring.
1406
+ *
1407
+ * Reads are size-bounded by contract so that a hostile or accidentally huge file cannot exhaust
1408
+ * memory. Writes are expected to be atomic: the default implementation writes to a temporary file
1409
+ * and renames it into place, so a crash mid-write never leaves a half-written file behind.
1410
+ * Directory creation is the caller's job, so an implementation whose `writeFile` targets a real
1411
+ * directory tree must also implement `mkdir`.
1412
+ */
1413
+ interface SdkFs {
1414
+ /** Reports whether a readable file exists at the path. */
1415
+ fileExists(path: string): Promise<boolean>;
1416
+ /** Reads a file as UTF-8 text, refusing to read more than `maxBytes`. */
1417
+ readFileBounded(path: string, maxBytes: number): Promise<BoundedFileRead>;
1418
+ /** Reads a file as raw bytes, refusing to read more than `maxBytes`. */
1419
+ readBytesBounded(path: string, maxBytes: number): Promise<BoundedBytesRead>;
1420
+ /** Writes UTF-8 text to the path, replacing any existing file atomically. */
1421
+ writeFile(path: string, data: string): Promise<void>;
1422
+ /** Writes raw bytes to the path, replacing any existing file atomically. */
1423
+ writeBytes(path: string, data: Uint8Array): Promise<void>;
1424
+ /**
1425
+ * Creates a file only if it does not already exist, returning `false` when it does. This is the
1426
+ * primitive behind the per-locale write lock, so it must be atomic against other processes.
1427
+ */
1428
+ createExclusive(path: string, data: string): Promise<boolean>;
1429
+ /** Removes the file at the path, succeeding even when it is already absent. */
1430
+ deleteFile(path: string): Promise<void>;
1431
+ /** Creates a directory and any missing parents. Optional; omit it if the backing store has no directories. */
1432
+ mkdir?(path: string): Promise<void>;
1433
+ /**
1434
+ * Lists one directory's immediate entries, without recursing and without following symbolic
1435
+ * links. Optional, so an implementation written before source extraction existed keeps working;
1436
+ * {@link extract} is the only entry point that needs it and reports its absence as
1437
+ * `EXTRACT_FS_UNSUPPORTED`.
1438
+ *
1439
+ * @param path - The directory to list.
1440
+ * @returns Every immediate entry with its kind. Rejects when the path is not a readable directory.
1441
+ */
1442
+ readDirectory?(path: string): Promise<readonly DirectoryEntry[]>;
1443
+ }
1444
+
1445
+ /**
1446
+ * The on-disk translation memory, read from and written to `verbatra.cache.json` (see
1447
+ * {@link CACHE_FILE_NAME}). A run consults it before calling the provider, so a string that was
1448
+ * already translated under the same configuration is reused instead of paid for again.
1449
+ *
1450
+ * Entries are keyed by configuration fingerprint, then locale, then source-content hash. The
1451
+ * fingerprint layer means that changing the provider, model, tone, or glossary does not silently
1452
+ * reuse translations produced under the old settings; those entries simply stop matching.
1453
+ *
1454
+ * `sources` sits beside them as a flat hash-to-text index. A hash cannot be compared for
1455
+ * similarity, so the source text itself has to be on file for a fuzzy match to have anything to
1456
+ * measure a changed string against. It is deliberately outside the fingerprint layer: the source
1457
+ * text a hash stands for does not depend on which provider translated it.
1458
+ */
1459
+ interface TranslationMemory {
1460
+ /** The cache schema version. A file from a newer version is ignored rather than trusted. */
1461
+ readonly version: number;
1462
+ /** Cached values, nested as configuration fingerprint, then locale, then source-content hash. */
1463
+ readonly entries: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, string>>>>>>;
1464
+ /**
1465
+ * The source text each content hash stands for. Empty for a cache carried forward from schema
1466
+ * version 1, which stored no source text; it fills in as later runs touch those hashes again.
1467
+ */
1468
+ readonly sources: Readonly<Record<string, string>>;
1469
+ }
1470
+
1471
+ /**
1472
+ * The file name of the project's translation memory, resolved against the run's working directory.
1473
+ * Commit it to reuse translations across machines and CI runs, or add it to `.gitignore` to treat
1474
+ * the cache as purely local. Deleting it is always safe: the next run simply repays for the strings
1475
+ * it would have reused.
1476
+ */
1477
+ declare const CACHE_FILE_NAME = "verbatra.cache.json";
1478
+
1479
+ /**
1480
+ * The zod schema for the `provider` block, discriminated on `id`. Each variant's options are
1481
+ * validated strictly, so an option that belongs to a different provider is reported as an error
1482
+ * rather than ignored. It is embedded in {@link verbatraConfigSchema} and produces
1483
+ * {@link ProviderConfig}.
1484
+ */
1485
+ declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1486
+ id: z.ZodLiteral<"anthropic">;
1487
+ options: z.ZodObject<{
1488
+ model: z.ZodString;
1489
+ maxTokens: z.ZodNumber;
1490
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1491
+ }, z.core.$strict>;
1492
+ }, z.core.$strip>, z.ZodObject<{
1493
+ id: z.ZodLiteral<"openai">;
1494
+ options: z.ZodObject<{
1495
+ model: z.ZodString;
1496
+ maxOutputTokens: z.ZodNumber;
1497
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1498
+ }, z.core.$strict>;
1499
+ }, z.core.$strip>, z.ZodObject<{
1500
+ id: z.ZodLiteral<"gemini">;
1501
+ options: z.ZodObject<{
1502
+ model: z.ZodString;
1503
+ maxOutputTokens: z.ZodNumber;
1504
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1505
+ }, z.core.$strict>;
1506
+ }, z.core.$strip>, z.ZodObject<{
1507
+ id: z.ZodLiteral<"deepl">;
1508
+ options: z.ZodObject<{
1509
+ glossaryId: z.ZodOptional<z.ZodString>;
1510
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1511
+ }, z.core.$strict>;
1512
+ }, z.core.$strip>, z.ZodObject<{
1513
+ id: z.ZodLiteral<"google-translate">;
1514
+ options: z.ZodObject<{
1515
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1516
+ }, z.core.$strict>;
1517
+ }, z.core.$strip>, z.ZodObject<{
1518
+ id: z.ZodLiteral<"openai-compatible">;
1519
+ options: z.ZodObject<{
1520
+ baseUrl: z.ZodURL;
1521
+ model: z.ZodString;
1522
+ maxOutputTokens: z.ZodNumber;
1523
+ apiKeyEnvVar: z.ZodOptional<z.ZodString>;
1524
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1525
+ }, z.core.$strict>;
1526
+ }, z.core.$strip>], "id">;
1527
+ /**
1528
+ * The `provider` block of a verbatra config: a discriminated union on `id`, so each provider
1529
+ * carries exactly the options it supports and nothing else.
1530
+ *
1531
+ * No variant has a field for an API key. Keys are read from the environment by the provider itself
1532
+ * (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPL_API_KEY`, or
1533
+ * `GOOGLE_TRANSLATE_API_KEY`), which is what keeps them out of config files and out of version
1534
+ * control. The `openai-compatible` variant may name a different environment variable through
1535
+ * `apiKeyEnvVar`, but still never holds the key itself.
1536
+ */
1537
+ type ProviderConfig = z.infer<typeof providerConfigSchema>;
1538
+ /**
1539
+ * The identifier of a supported translation provider: `anthropic`, `openai`, `gemini`, `deepl`,
1540
+ * `google-translate`, or `openai-compatible`. The last one targets a local or self-hosted server
1541
+ * that speaks the OpenAI chat-completions API.
1542
+ */
1543
+ type ProviderId = ProviderConfig["id"];
1544
+
1545
+ /**
1546
+ * The zod schema every verbatra config is validated against. {@link loadConfig} applies it, so most
1547
+ * consumers never call it directly; reach for it when you build a config programmatically and want
1548
+ * to validate it before handing it to {@link translate}, or when you surface config errors in your
1549
+ * own tooling.
1550
+ *
1551
+ * The object is strict, so an unrecognized key is an error rather than being silently ignored: a
1552
+ * typo in a config file is reported instead of quietly doing nothing. The one exception is the
1553
+ * optional `$schema` key, accepted so a JSON or YAML config can point an editor at the JSON Schema
1554
+ * document the package ships as `@verbatra/sdk/config-schema.json`.
1555
+ *
1556
+ * `fuzzyCache` is off unless `enabled` is set. With it on, a source string whose earlier form is in
1557
+ * the translation memory close enough to clear `threshold` reuses that translation instead of
1558
+ * paying the provider for it. `threshold` is a similarity ratio from `0.5` to `1` and defaults to `0.9`.
1559
+ *
1560
+ * Beyond the per-field checks, two whole-config rules are enforced: `targetLocales` must not
1561
+ * contain the source locale, and it must not contain two locales that differ only in case (they
1562
+ * would collide on a case-insensitive file system). The `{locale}` token requirement is a
1563
+ * per-field check on `files.pattern`.
1564
+ */
1565
+ declare const verbatraConfigSchema: z.ZodObject<{
1566
+ $schema: z.ZodOptional<z.ZodString>;
1567
+ sourceLocale: z.ZodString;
1568
+ targetLocales: z.ZodArray<z.ZodString>;
1569
+ format: z.ZodUnion<readonly [z.ZodEnum<{
1570
+ "i18next-json": "i18next-json";
1571
+ "vue-i18n-json": "vue-i18n-json";
1572
+ "next-intl-json": "next-intl-json";
1573
+ "ngx-translate-json": "ngx-translate-json";
1574
+ xliff: "xliff";
469
1575
  yaml: "yaml";
470
1576
  arb: "arb";
471
1577
  properties: "properties";
472
- }>;
1578
+ "apple-strings": "apple-strings";
1579
+ "apple-xcstrings": "apple-xcstrings";
1580
+ "android-xml": "android-xml";
1581
+ "gettext-po": "gettext-po";
1582
+ ini: "ini";
1583
+ resx: "resx";
1584
+ }>, z.ZodTemplateLiteral<`custom:${string}`>]>;
473
1585
  files: z.ZodObject<{
474
1586
  pattern: z.ZodString;
475
1587
  localeStyle: z.ZodOptional<z.ZodEnum<{
@@ -505,6 +1617,11 @@ declare const verbatraConfigSchema: z.ZodObject<{
505
1617
  glossaryId: z.ZodOptional<z.ZodString>;
506
1618
  requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
507
1619
  }, z.core.$strict>;
1620
+ }, z.core.$strip>, z.ZodObject<{
1621
+ id: z.ZodLiteral<"google-translate">;
1622
+ options: z.ZodObject<{
1623
+ requestTimeoutMs: z.ZodOptional<z.ZodNumber>;
1624
+ }, z.core.$strict>;
508
1625
  }, z.core.$strip>, z.ZodObject<{
509
1626
  id: z.ZodLiteral<"openai-compatible">;
510
1627
  options: z.ZodObject<{
@@ -523,12 +1640,40 @@ declare const verbatraConfigSchema: z.ZodObject<{
523
1640
  }>>;
524
1641
  prune: z.ZodOptional<z.ZodBoolean>;
525
1642
  generatePlurals: z.ZodOptional<z.ZodBoolean>;
1643
+ fuzzyCache: z.ZodOptional<z.ZodObject<{
1644
+ enabled: z.ZodBoolean;
1645
+ threshold: z.ZodOptional<z.ZodNumber>;
1646
+ }, z.core.$strict>>;
526
1647
  maxBatchSize: z.ZodOptional<z.ZodNumber>;
1648
+ maxLength: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
527
1649
  maxTokens: z.ZodOptional<z.ZodNumber>;
528
1650
  budgetBehavior: z.ZodOptional<z.ZodEnum<{
529
1651
  warn: "warn";
530
1652
  stop: "stop";
531
1653
  }>>;
1654
+ rates: z.ZodOptional<z.ZodObject<{
1655
+ asOf: z.ZodISODate;
1656
+ currency: z.ZodString;
1657
+ table: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
1658
+ inputPerMillionTokens: z.ZodNumber;
1659
+ outputPerMillionTokens: z.ZodNumber;
1660
+ }, z.core.$strict>, z.ZodObject<{
1661
+ perMillionCharacters: z.ZodNumber;
1662
+ }, z.core.$strict>]>>;
1663
+ }, z.core.$strict>>;
1664
+ extract: z.ZodOptional<z.ZodObject<{
1665
+ framework: z.ZodEnum<{
1666
+ i18next: "i18next";
1667
+ }>;
1668
+ roots: z.ZodArray<z.ZodString>;
1669
+ exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
1670
+ literals: z.ZodOptional<z.ZodObject<{
1671
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
1672
+ }, z.core.$strict>>;
1673
+ unused: z.ZodOptional<z.ZodObject<{
1674
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
1675
+ }, z.core.$strict>>;
1676
+ }, z.core.$strict>>;
532
1677
  }, z.core.$strict>;
533
1678
  /**
534
1679
  * A config exactly as it is written in a `verbatra.config.ts` file, before the SDK resolves
@@ -578,9 +1723,9 @@ type AuthoringVariant<Id extends ProviderId, M extends string> = Extract<Provide
578
1723
  } : never : never;
579
1724
  /**
580
1725
  * Maps each provider ID to its authoring-time config variant. The three language-model providers
581
- * get narrowed model unions; DeepL has no model to narrow, and `openai-compatible` deliberately
582
- * keeps a free-form model string because the endpoint is a local or self-hosted server whose model
583
- * list the SDK cannot know ahead of time.
1726
+ * get narrowed model unions; DeepL and Google Cloud Translation have no model to narrow, and
1727
+ * `openai-compatible` deliberately keeps a free-form model string because the endpoint is a local
1728
+ * or self-hosted server whose model list the SDK cannot know ahead of time.
584
1729
  */
585
1730
  type AuthoringProviderVariant = {
586
1731
  /** Anthropic, with `model` narrowed to the Claude model IDs. */
@@ -593,6 +1738,13 @@ type AuthoringProviderVariant = {
593
1738
  deepl: Extract<ProviderConfig, {
594
1739
  id: "deepl";
595
1740
  }>;
1741
+ /**
1742
+ * Google Cloud Translation (Basic, v2), which takes no model for the same reason as DeepL: it is
1743
+ * a machine-translation API rather than a language model.
1744
+ */
1745
+ "google-translate": Extract<ProviderConfig, {
1746
+ id: "google-translate";
1747
+ }>;
596
1748
  /** A local or self-hosted OpenAI-compatible endpoint, whose model stays a free-form string. */
597
1749
  "openai-compatible": Extract<ProviderConfig, {
598
1750
  id: "openai-compatible";
@@ -671,6 +1823,14 @@ declare function defineConfig(config: AuthoringConfigFor<"gemini">): VerbatraCon
671
1823
  * @returns The same object, typed as {@link VerbatraConfigInput}.
672
1824
  */
673
1825
  declare function defineConfig(config: AuthoringConfigFor<"deepl">): VerbatraConfigInput;
1826
+ /**
1827
+ * Types a `verbatra.config.ts` narrowed to the Google Cloud Translation provider. Like DeepL, it is
1828
+ * a machine-translation API rather than a language model, so its options carry no model field.
1829
+ *
1830
+ * @param config - The config object, narrowed to the Google Cloud Translation provider.
1831
+ * @returns The same object, typed as {@link VerbatraConfigInput}.
1832
+ */
1833
+ declare function defineConfig(config: AuthoringConfigFor<"google-translate">): VerbatraConfigInput;
674
1834
  /**
675
1835
  * Types a `verbatra.config.ts` for any supported provider. This is the fallback overload, and the
676
1836
  * one that serves `openai-compatible`, where the model is a free-form string because the endpoint
@@ -681,6 +1841,46 @@ declare function defineConfig(config: AuthoringConfigFor<"deepl">): VerbatraConf
681
1841
  */
682
1842
  declare function defineConfig(config: AuthoringConfig): VerbatraConfigInput;
683
1843
 
1844
+ /**
1845
+ * The zod schema for the optional `extract` block, validated strictly so a misspelled field is
1846
+ * reported rather than silently ignored. It is embedded in {@link verbatraConfigSchema} and
1847
+ * produces {@link ExtractionConfig}.
1848
+ */
1849
+ declare const extractionConfigSchema: z.ZodObject<{
1850
+ framework: z.ZodEnum<{
1851
+ i18next: "i18next";
1852
+ }>;
1853
+ roots: z.ZodArray<z.ZodString>;
1854
+ exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
1855
+ literals: z.ZodOptional<z.ZodObject<{
1856
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
1857
+ }, z.core.$strict>>;
1858
+ unused: z.ZodOptional<z.ZodObject<{
1859
+ ignore: z.ZodOptional<z.ZodArray<z.ZodString>>;
1860
+ }, z.core.$strict>>;
1861
+ }, z.core.$strict>;
1862
+ /**
1863
+ * The `extract` block of a verbatra config: which translation framework's call sites the source
1864
+ * scan looks for, which directories it walks, and which directory names it skips on the way.
1865
+ *
1866
+ * `roots` are resolved against the run's working directory, and the scan never reads outside them.
1867
+ * `exclude` adds directory names to the set that is always skipped, which already covers
1868
+ * `node_modules`, `.git`, and the usual build output directories.
1869
+ *
1870
+ * `literals.ignore` lists string literals the untranslated-literal scan (`verbatra doctor
1871
+ * --literals`) holds back project-wide, matched against the whole literal text as it is reported:
1872
+ * whitespace collapsed and, in JSX text or a quoted JSX attribute value, character references such
1873
+ * as `&amp;` decoded, so an entry is written `Tom & Jerry`, not `Tom &amp; Jerry`. A held-back
1874
+ * literal is still reported, under `suppressed`, so nothing vanishes silently.
1875
+ *
1876
+ * `unused.ignore` names catalog keys the unused-key report must not count as unused, for a key
1877
+ * referenced only from outside the scanned source (a server template, a CMS, a test fixture). Each
1878
+ * entry is an exact key, or a pattern in which `*` matches any run of characters, such as
1879
+ * `emails.*`. It matches the decoded key as the report shows it (`Welcome. Enjoy`, not the escaped
1880
+ * catalog spelling). A matched key is reported as ignored, never dropped from the report.
1881
+ */
1882
+ type ExtractionConfig = z.infer<typeof extractionConfigSchema>;
1883
+
684
1884
  /**
685
1885
  * Where a loaded config's glossary came from, reported by {@link loadConfigWithMeta}. The resolved
686
1886
  * {@link VerbatraConfig} holds only the final term map, so this is the one place a tool can tell an
@@ -890,6 +2090,67 @@ declare function loadConfigWithMeta(options?: LoadConfigOptions): Promise<Loaded
890
2090
  */
891
2091
  declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig>;
892
2092
 
2093
+ /**
2094
+ * What a provider charges for: `tokens` for the prompt-driven LLMs, which bill the prompt and the
2095
+ * completion separately, and `characters` for the machine-translation APIs, which bill the source
2096
+ * text they are handed and report no token usage at all.
2097
+ */
2098
+ type BillingUnit = "tokens" | "characters";
2099
+ /**
2100
+ * How one provider bills. `billedByApi` is false for a self-hosted endpoint, which still consumes
2101
+ * tokens but sends no invoice, so an estimate reports its quantity and no currency figure.
2102
+ */
2103
+ interface ProviderBilling {
2104
+ /** The quantity this provider charges for. */
2105
+ readonly unit: BillingUnit;
2106
+ /** Whether a hosted API bills for the work at all. */
2107
+ readonly billedByApi: boolean;
2108
+ }
2109
+
2110
+ declare const tokenRateSchema: z.ZodObject<{
2111
+ inputPerMillionTokens: z.ZodNumber;
2112
+ outputPerMillionTokens: z.ZodNumber;
2113
+ }, z.core.$strict>;
2114
+ declare const characterRateSchema: z.ZodObject<{
2115
+ perMillionCharacters: z.ZodNumber;
2116
+ }, z.core.$strict>;
2117
+ declare const modelRateSchema: z.ZodUnion<readonly [z.ZodObject<{
2118
+ inputPerMillionTokens: z.ZodNumber;
2119
+ outputPerMillionTokens: z.ZodNumber;
2120
+ }, z.core.$strict>, z.ZodObject<{
2121
+ perMillionCharacters: z.ZodNumber;
2122
+ }, z.core.$strict>]>;
2123
+ declare const rateCardSchema: z.ZodObject<{
2124
+ asOf: z.ZodISODate;
2125
+ currency: z.ZodString;
2126
+ table: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
2127
+ inputPerMillionTokens: z.ZodNumber;
2128
+ outputPerMillionTokens: z.ZodNumber;
2129
+ }, z.core.$strict>, z.ZodObject<{
2130
+ perMillionCharacters: z.ZodNumber;
2131
+ }, z.core.$strict>]>>;
2132
+ }, z.core.$strict>;
2133
+ /**
2134
+ * A rate charging by tokens, with the prompt and the completion priced separately. Every hosted LLM
2135
+ * provider bills this way.
2136
+ */
2137
+ type TokenRate = z.infer<typeof tokenRateSchema>;
2138
+ /** A rate charging by source characters, which is how the machine-translation APIs bill. */
2139
+ type CharacterRate = z.infer<typeof characterRateSchema>;
2140
+ /** One entry of a {@link RateCard}: either a token rate or a character rate, never both. */
2141
+ type ModelRate = z.infer<typeof modelRateSchema>;
2142
+ /**
2143
+ * The rates a project supplies so an estimate can be expressed in money. verbatra ships no prices of
2144
+ * its own: they are a third party's, they change without notice, and a number baked into a release
2145
+ * would quietly go stale. The card carries the date it was read and the currency it is written in,
2146
+ * so any figure derived from it is visibly as old as `asOf` says it is.
2147
+ *
2148
+ * `table` is keyed by rate key: `provider/model` for a provider configured with a model (for
2149
+ * example `gemini/gemini-2.5-flash`), and the bare provider id for one without (`deepl`,
2150
+ * `google-translate`).
2151
+ */
2152
+ type RateCard = z.infer<typeof rateCardSchema>;
2153
+
893
2154
  /**
894
2155
  * The stable, structured failure codes the SDK throws. Branch on {@link SdkError.code} rather than
895
2156
  * on a message; the messages are written for humans and are not part of the contract.
@@ -958,18 +2219,42 @@ declare function loadConfig(options?: LoadConfigOptions): Promise<VerbatraConfig
958
2219
  * {@link translate} before any locale runs, and by {@link watch} once at startup, before any
959
2220
  * watching begins, since the value is fixed for the session rather than re-read per run.
960
2221
  * - `CONCURRENCY_BUDGET_CONFLICT`: a live run requested a `concurrency` above 1 while a token
961
- * budget is configured. The two are mutually exclusive because concurrency makes the budget's
962
- * stop guarantee nondeterministic. A dry run is exempt, since it never consults the budget.
2222
+ * budget is configured. The ceiling itself would still hold, but which locale loses its
2223
+ * remaining work would depend on the order the locales interleave, so the same project would not
2224
+ * produce the same run twice. A dry run is exempt, since it never consults the budget.
963
2225
  * - `TARGET_UNWRITABLE`: a target locale file could not be written, because its directory is not
964
2226
  * writable, does not exist, is read-only, or is out of space. The message names the target file
965
2227
  * relative to `cwd` and the underlying file-system code, never the internal temporary file the
966
2228
  * atomic write uses. Thrown by {@link editEntry} and {@link retranslateEntry}, which act on one
967
2229
  * locale. {@link translate} and {@link importWorkbook} do not throw it: they record it on that
968
2230
  * locale's {@link LocaleSummary} and carry on with the other locales.
2231
+ * - `PSEUDO_OUTPUT_CONFLICT`: {@link pseudolocalize} was asked to generate a pseudolocale that
2232
+ * names a configured locale, or to write one onto a configured locale file. Refused before
2233
+ * anything is read or written, so a generated pseudolocale can never overwrite a real
2234
+ * translation or stand in for one.
2235
+ * - `SOURCE_UNWRITABLE`: the source locale file could not be written. Thrown by {@link extract}
2236
+ * alone, since it is the only entry point that writes the source locale. The `xliff` and
2237
+ * `apple-xcstrings` formats reach it when no catalog exists yet, because neither is created from
2238
+ * nothing.
2239
+ * - `EXTRACT_NOT_CONFIGURED`: {@link extract} was called with a config that carries no `extract`
2240
+ * block, so there is no framework to look for and no source root to walk.
2241
+ * - `EXTRACT_FS_UNSUPPORTED`: {@link extract} was given a `deps.fs` that implements no
2242
+ * `readDirectory`, so no source file can be discovered. The member is optional on {@link SdkFs}
2243
+ * precisely so an implementation written before extraction existed keeps compiling; this is the
2244
+ * error it gets if it is then handed to {@link extract}.
2245
+ * - `TYPES_OUTPUT_CONFLICT`: {@link generateTypes} refused its output path. Before anything is
2246
+ * read or written, it refuses a path that names no file, is absolute, climbs out of the working
2247
+ * directory, or does not end in `.ts`, `.mts` or `.cts`, and one naming a configured locale
2248
+ * file, the lock file, the translation-memory cache, a file verbatra searches for its
2249
+ * configuration, or the configuration file the run loaded, compared case-insensitively. A
2250
+ * generating run, never a `check` run, also refuses to replace an existing file there that does
2251
+ * not begin with the header line verbatra writes, and leaves that file untouched.
2252
+ * - `TYPES_UNWRITABLE`: the declaration file {@link generateTypes} produces could not be written,
2253
+ * because its directory is not writable, does not exist, or the disk is out of space.
969
2254
  * - `LOCALE_FAILED`: never thrown. It is the fallback code recorded on a failed
970
2255
  * {@link LocaleSummary} when a per-locale failure carries no code of its own.
971
2256
  */
972
- 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";
2257
+ 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" | "PSEUDO_OUTPUT_CONFLICT" | "SOURCE_UNWRITABLE" | "EXTRACT_NOT_CONFIGURED" | "EXTRACT_FS_UNSUPPORTED" | "TYPES_OUTPUT_CONFLICT" | "TYPES_UNWRITABLE" | "LOCALE_FAILED";
973
2258
  /**
974
2259
  * The single structured error the SDK throws. Every whole-run failure surfaces as an `SdkError`
975
2260
  * carrying a stable {@link SdkErrorCode}; per-locale failures, provider notices, and integrity
@@ -977,275 +2262,504 @@ type SdkErrorCode = "CONFIG_NOT_FOUND" | "CONFIG_INVALID" | "UNKNOWN_FORMAT" | "
977
2262
  *
978
2263
  * An `SdkError` never carries a secret in its message.
979
2264
  */
980
- declare class SdkError extends Error {
981
- /** The stable {@link SdkErrorCode} for this failure. Branch on this, not on the message. */
982
- readonly code: SdkErrorCode;
2265
+ declare class SdkError extends Error {
2266
+ /** The stable {@link SdkErrorCode} for this failure. Branch on this, not on the message. */
2267
+ readonly code: SdkErrorCode;
2268
+ /**
2269
+ * @param code - The stable failure code.
2270
+ * @param message - A human-readable description of the failure. Never contains a secret.
2271
+ * @param options - `cause` carries the error this one wraps, such as the interchange reader's
2272
+ * error for a `SOURCE_INVALID` TMX file. Read that file's line, column and unit with
2273
+ * `tmxErrorLocation` rather than from the cause directly.
2274
+ */
2275
+ constructor(code: SdkErrorCode, message: string, options?: {
2276
+ readonly cause?: unknown;
2277
+ });
2278
+ }
2279
+
2280
+ /**
2281
+ * Conditions the SDK itself reports on a locale, as opposed to the {@link ProviderNotice} codes a
2282
+ * provider raises. Each marks a run that completed but did something a caller may want to know
2283
+ * about.
2284
+ *
2285
+ * - `PLURAL_CATEGORIES_INCOMPLETE`: plural generation could not produce every category the target
2286
+ * language requires, so the entry is written with the categories that were produced.
2287
+ * - `SUB_BATCH_FAILED`: one sub-batch of a locale failed while others succeeded. The locale is
2288
+ * reported as `partial` rather than failed.
2289
+ * - `BLANK_ROW_BASELINE_RETAINED`: an imported handoff row was blank, so the existing translation
2290
+ * and its lock-file baseline were kept rather than being erased.
2291
+ * - `BUDGET_TOKENS_EXCEEDED`: the configured token budget was reached. Under `warn` the run
2292
+ * continues and the overrun is reported; under `stop` the request that would have crossed the
2293
+ * ceiling is withheld before it is sent, and so is every request after it.
2294
+ * - `CACHE_VERSION_UNRECOGNIZED`: the translation memory is at a version this release does not
2295
+ * understand, so it was ignored rather than trusted.
2296
+ */
2297
+ type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED" | "CACHE_VERSION_UNRECOGNIZED";
2298
+ /**
2299
+ * Token usage as reported by the provider. Absent when the provider does not report usage, which is
2300
+ * the case for machine-translation APIs such as DeepL that do not bill in tokens.
2301
+ */
2302
+ interface UsageSummary {
2303
+ /** Tokens consumed by the prompts sent to the provider. */
2304
+ readonly inputTokens: number;
2305
+ /** Tokens consumed by the provider's responses. */
2306
+ readonly outputTokens: number;
2307
+ }
2308
+ /**
2309
+ * What a run does about its token budget. `warn`, the default, counts the spend and reports the
2310
+ * overrun without withholding anything. `stop` turns the figure into a ceiling: before each
2311
+ * provider request the run projects what that request will cost, and withholds it, and everything
2312
+ * after it, rather than send a request that would take the run past `maxTokens`.
2313
+ */
2314
+ type BudgetBehavior = "warn" | "stop";
2315
+ /**
2316
+ * The token budget in force for one run, and how much of it that run consumed.
2317
+ *
2318
+ * It is enforced for every provider, including the machine-translation APIs that report no usage of
2319
+ * their own: those are counted from the same projection the pre-run estimate uses, so a ceiling set
2320
+ * against DeepL or Google Cloud Translation withholds work rather than sitting inert.
2321
+ * {@link supported} says which of the two the figure came from.
2322
+ *
2323
+ * It bounds one run, not a session. {@link watch} starts a fresh budget for every run it triggers,
2324
+ * so a watch session can spend up to `maxTokens` again on each source edit. It also covers
2325
+ * {@link translate} and nothing else: `retranslateEntry` constructs its own provider on a separate
2326
+ * path, reached from the Studio dashboard and the agent tools, and no budget here caps that spend.
2327
+ *
2328
+ * Under `stop` the ceiling is checked before every request the run sends, each half of a re-split
2329
+ * request included, and a refused request is never sent. What a reservation cannot bound is what
2330
+ * happens inside a request it already admitted. The provider layer sends one repair call of its own
2331
+ * when keys come back missing, so one admitted batch can cost up to about twice its projection; and
2332
+ * the count is only as good as what the provider reports, so a provider under-reporting its usage
2333
+ * would leave the ceiling further away than it looks. Both land at reconciliation, and nothing
2334
+ * further is admitted once they do. A request that fails or comes back truncated keeps its whole
2335
+ * projection charged rather than being refunded, because such a call has usually already billed for
2336
+ * its prompt.
2337
+ */
2338
+ interface RunBudget {
2339
+ /** The configured ceiling, in tokens. */
2340
+ readonly maxTokens: number;
2341
+ /** Whether passing the ceiling only warns, or withholds work to stay under it. */
2342
+ readonly behavior: BudgetBehavior;
2343
+ /**
2344
+ * Whether {@link tokensUsed} is entirely the provider's own reported usage. False as soon as one
2345
+ * counted request came back without a usable figure, so the total is partly verbatra's own
2346
+ * projection: a machine-translation API reports none at all, and a failed or truncated request
2347
+ * reports none either. The budget is enforced either way.
2348
+ */
2349
+ readonly supported: boolean;
2350
+ /**
2351
+ * Tokens counted across the run: reported usage for every request that gave one, and the
2352
+ * projection for every request that did not.
2353
+ */
2354
+ readonly tokensUsed: number;
2355
+ /**
2356
+ * True once the run reached its ceiling: either `tokensUsed` reached or passed `maxTokens`, or,
2357
+ * under `stop`, a request was withheld because sending it would have passed it.
2358
+ */
2359
+ readonly exceeded: boolean;
2360
+ }
2361
+ /**
2362
+ * Why a {@link RunEstimate} does or does not carry a currency figure.
2363
+ *
2364
+ * - `priced`: a rate for this provider and model was found on the configured rate card and applied.
2365
+ * - `no-rate-on-file`: no rate was configured for {@link RunEstimate.rateKey}. The quantity is still
2366
+ * reported; the money is not, because verbatra ships no prices of its own and will not guess one.
2367
+ * - `rate-unit-mismatch`: a rate exists but is written in the wrong unit for this provider, for
2368
+ * instance a per-character price against a token-billed model. It is refused rather than applied.
2369
+ * - `not-billed`: the provider is a self-hosted endpoint, so no API bills for the run at all.
2370
+ */
2371
+ type EstimatePricing = "priced" | "no-rate-on-file" | "rate-unit-mismatch" | "not-billed";
2372
+ /**
2373
+ * What a pre-run estimate deliberately leaves out. Each code names a way the real run can land
2374
+ * below the figure, above it, or away from it, so an estimate is never mistaken for an invoice.
2375
+ *
2376
+ * - `CACHE_NOT_CONSULTED`: the estimate does not read the translation memory, so keys a live run
2377
+ * would serve from cache are still counted.
2378
+ * - `SOURCE_DUPLICATES_NOT_DEDUPLICATED`: a live run sends one representative per identical source
2379
+ * string; the estimate counts every key.
2380
+ * - `TRANSPORT_RETRIES_NOT_COUNTED`: the estimate counts one billable call per planned request. The
2381
+ * provider SDKs retry a failed call underneath that, so one planned request can become several
2382
+ * attempts on the wire, and a retried attempt that reached the model is still billed.
2383
+ * - `TRANSLATION_LENGTH_IS_ESTIMATED`: the translation does not exist yet, so the response is sized
2384
+ * from the source value plus a fixed expansion allowance. A target language that runs longer than
2385
+ * the allowance reports more completion tokens than this figure predicts.
2386
+ * - `TOKEN_COUNT_IS_HEURISTIC`: tokens are derived from character counts, not from the provider's
2387
+ * own tokenizer.
2388
+ * - `REPAIR_REQUESTS_NOT_COUNTED`: the extra requests an incomplete response can trigger are not
2389
+ * counted: one bounded repair round when keys came back missing, and a retry in halves, which
2390
+ * recurses, when the output was cut off. A repair round re-sends the system rules, the glossary
2391
+ * and the tone in full, so repairing one key in a large-glossary batch costs close to a whole
2392
+ * extra request.
2393
+ */
2394
+ type EstimateCaveatCode = "CACHE_NOT_CONSULTED" | "SOURCE_DUPLICATES_NOT_DEDUPLICATED" | "TRANSPORT_RETRIES_NOT_COUNTED" | "TRANSLATION_LENGTH_IS_ESTIMATED" | "TOKEN_COUNT_IS_HEURISTIC" | "REPAIR_REQUESTS_NOT_COUNTED";
2395
+ /**
2396
+ * The estimated billing quantity for one target locale, without the money. `inputTokens` and
2397
+ * `outputTokens` are present only for a token-billed provider, and `sourceCharacters` only for a
2398
+ * character-billed one, so a machine-translation API is never handed a token figure it does not
2399
+ * bill by.
2400
+ */
2401
+ interface LocaleEstimateQuantity {
2402
+ /** The target locale this estimate covers. */
2403
+ readonly locale: string;
2404
+ /** How many keys would be sent for this locale, translation and plural generation together. */
2405
+ readonly keys: number;
2406
+ /** How many provider requests those keys would be split into. */
2407
+ readonly requests: number;
2408
+ /** Estimated prompt tokens. Present only when the provider bills by tokens. */
2409
+ readonly inputTokens?: number;
2410
+ /** Estimated completion tokens. Present only when the provider bills by tokens. */
2411
+ readonly outputTokens?: number;
2412
+ /** Estimated source characters. Present only when the provider bills by characters. */
2413
+ readonly sourceCharacters?: number;
2414
+ }
2415
+ /** One locale of a {@link PricedRunEstimate}: the quantity plus the money it comes to. */
2416
+ interface PricedLocaleEstimate extends LocaleEstimateQuantity {
2417
+ /** The estimated cost for this locale, in {@link PricedRunEstimate.currency}. */
2418
+ readonly cost: number;
2419
+ }
2420
+ /** One locale of an {@link UnpricedRunEstimate}: the quantity, and deliberately no money. */
2421
+ interface UnpricedLocaleEstimate extends LocaleEstimateQuantity {
2422
+ /** Never present: the run carries no rate, so no locale of it carries a figure. */
2423
+ readonly cost?: undefined;
2424
+ }
2425
+ /**
2426
+ * One target locale of a {@link RunEstimate}. It carries a `cost` exactly when the run it belongs
2427
+ * to was priced, so there is no such thing as a priced estimate with a costless locale.
2428
+ */
2429
+ type LocaleEstimate = PricedLocaleEstimate | UnpricedLocaleEstimate;
2430
+ /**
2431
+ * Everything a {@link RunEstimate} reports regardless of whether it could be priced: which provider
2432
+ * the figure was computed for, how much would be sent, and what the figure leaves out.
2433
+ */
2434
+ interface EstimateIdentity {
2435
+ /** The provider the estimate was computed for. */
2436
+ readonly provider: ProviderId;
2437
+ /** The configured model, absent for a provider that takes none (`deepl`, `google-translate`). */
2438
+ readonly model?: string;
2439
+ /**
2440
+ * The key a rate is filed under in the config's `rates.table`: `provider/model` for a provider
2441
+ * configured with a model, and the bare provider id otherwise.
2442
+ */
2443
+ readonly rateKey: string;
2444
+ /**
2445
+ * Keys that would be sent across every locale, counting both the keys that would be translated
2446
+ * and the plural forms that would be generated.
2447
+ */
2448
+ readonly keys: number;
2449
+ /** Provider requests across every locale, generation batches included. */
2450
+ readonly requests: number;
2451
+ /** What this figure leaves out. Always present, never empty. See {@link EstimateCaveatCode}. */
2452
+ readonly caveats: readonly EstimateCaveatCode[];
2453
+ }
2454
+ /** The quantity a token-billed provider charges for: the prompt and the completion, separately. */
2455
+ interface TokenRunQuantity extends EstimateIdentity {
2456
+ /** Always `tokens` for a prompt-driven LLM. */
2457
+ readonly unit: "tokens";
2458
+ /** Estimated prompt tokens across every locale. */
2459
+ readonly inputTokens: number;
2460
+ /** Estimated completion tokens across every locale. */
2461
+ readonly outputTokens: number;
2462
+ /** Never present: a token-billed provider does not bill source characters. */
2463
+ readonly sourceCharacters?: undefined;
2464
+ }
2465
+ /** The quantity a character-billed provider charges for: the source text handed to it. */
2466
+ interface CharacterRunQuantity extends EstimateIdentity {
2467
+ /** Always `characters` for a machine-translation API. */
2468
+ readonly unit: "characters";
2469
+ /** Estimated source characters across every locale. */
2470
+ readonly sourceCharacters: number;
2471
+ /** Never present: a machine-translation API reports no token usage at all. */
2472
+ readonly inputTokens?: undefined;
2473
+ /** Never present: a machine-translation API reports no token usage at all. */
2474
+ readonly outputTokens?: undefined;
2475
+ }
2476
+ /**
2477
+ * What a run would send, discriminated on {@link unit} so the billed dimension is always present
2478
+ * and the other one is never readable as a zero.
2479
+ */
2480
+ type RunEstimateQuantity = TokenRunQuantity | CharacterRunQuantity;
2481
+ interface PricedEstimateMoney {
2482
+ /** Always `priced`: a rate was found and applied. */
2483
+ readonly pricing: "priced";
2484
+ /** The rate card's currency code. */
2485
+ readonly currency: string;
983
2486
  /**
984
- * @param code - The stable failure code.
985
- * @param message - A human-readable description of the failure. Never contains a secret.
2487
+ * The date the rates were read, as the config declared it. Print it beside any figure: a rate
2488
+ * card is exactly as current as this date.
986
2489
  */
987
- constructor(code: SdkErrorCode, message: string);
2490
+ readonly asOf: string;
2491
+ /** The per-locale breakdown, in the same order as {@link RunSummary.locales}. */
2492
+ readonly locales: readonly PricedLocaleEstimate[];
2493
+ /** The estimated total, in {@link currency}. */
2494
+ readonly cost: number;
2495
+ }
2496
+ interface UnpricedEstimateMoney {
2497
+ /** Why no currency figure is present. See {@link EstimatePricing}. */
2498
+ readonly pricing: Exclude<EstimatePricing, "priced">;
2499
+ /** The per-locale breakdown, in the same order as {@link RunSummary.locales}. */
2500
+ readonly locales: readonly UnpricedLocaleEstimate[];
2501
+ /** Never present: there is no rate to express a figure in. */
2502
+ readonly currency?: undefined;
2503
+ /** Never present: there is no rate, so there is no date it was read on. */
2504
+ readonly asOf?: undefined;
2505
+ /** Never present: an absent rate is reported as absent, never as a cost of zero. */
2506
+ readonly cost?: undefined;
2507
+ }
2508
+ interface TokenPricedRunEstimate extends TokenRunQuantity, PricedEstimateMoney {
2509
+ }
2510
+ interface CharacterPricedRunEstimate extends CharacterRunQuantity, PricedEstimateMoney {
2511
+ }
2512
+ interface TokenUnpricedRunEstimate extends TokenRunQuantity, UnpricedEstimateMoney {
2513
+ }
2514
+ interface CharacterUnpricedRunEstimate extends CharacterRunQuantity, UnpricedEstimateMoney {
988
2515
  }
989
-
990
2516
  /**
991
- * Zod schema accepting exactly one of the eight source formats verbatra can read and write. Each
992
- * member names the ecosystem it round-trips, not just a file extension, because several of them share
993
- * one: `i18next-json` is i18next's nested JSON with its plural key suffixes, `vue-i18n-json` is Vue
994
- * I18n's JSON with its pipe-separated plural values, `next-intl-json` is next-intl's ICU-message JSON,
995
- * and `ngx-translate-json` is ngx-translate's nested JSON. The remaining four are `xliff` (the XLIFF
996
- * interchange XML), `yaml` (plain nested YAML), `arb` (Flutter's Application Resource Bundle), and
997
- * `properties` (Java and Spring `.properties` files).
998
- *
999
- * The set is closed: a format outside it cannot be represented, so adding one means adding a member
1000
- * here and an adapter that claims it.
2517
+ * An estimate a rate could be applied to: the config supplied a rate for `rateKey` in the right
2518
+ * unit, so the run carries a currency figure and the date that rate was read.
1001
2519
  */
1002
- declare const supportedFormatSchema: z.ZodEnum<{
1003
- "i18next-json": "i18next-json";
1004
- "vue-i18n-json": "vue-i18n-json";
1005
- "next-intl-json": "next-intl-json";
1006
- "ngx-translate-json": "ngx-translate-json";
1007
- xliff: "xliff";
1008
- yaml: "yaml";
1009
- arb: "arb";
1010
- properties: "properties";
1011
- }>;
1012
- /** One of the eight supported source formats; the inferred type of {@link supportedFormatSchema}. */
1013
- type SupportedFormat = z.infer<typeof supportedFormatSchema>;
1014
-
2520
+ type PricedRunEstimate = TokenPricedRunEstimate | CharacterPricedRunEstimate;
1015
2521
  /**
1016
- * A single, format-neutral translation unit. Placeholders are supplied already extracted; core never
1017
- * derives them from the value, so an adapter that reads a file is the one authority on what counts as
1018
- * a placeholder in that format.
1019
- *
1020
- * The fields are: `key`, the entry's identifier within its namespace, non-empty and already flattened
1021
- * to the dotted path an adapter produced; `namespace`, the namespace the entry belongs to, empty when
1022
- * the format has no namespacing; `value`, the translatable string itself, which is untrusted input and
1023
- * may be empty; `description` and `meaning`, both optional free-form context that a provider is told to
1024
- * use only for disambiguation and never to translate or echo back; `placeholders`, the format's
1025
- * placeholder tokens in document order, used for the output integrity check and to decide which entries
1026
- * a placeholder-blind provider must withhold; and `isPlural`, whether the entry carries plural forms
1027
- * (an i18next plural key suffix, a vue-i18n pipe-separated value, or an ICU plural), which the adapter
1028
- * decides and which feeds the content hash.
2522
+ * An estimate carrying quantity and no money, with `pricing` saying why. verbatra ships no prices
2523
+ * of its own and will not guess one, so the absence is reported rather than rendered as a cost of
2524
+ * zero.
1029
2525
  */
1030
- declare const translationEntrySchema: z.ZodObject<{
1031
- key: z.ZodString;
1032
- namespace: z.ZodString;
1033
- value: z.ZodString;
1034
- description: z.ZodOptional<z.ZodString>;
1035
- meaning: z.ZodOptional<z.ZodString>;
1036
- placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
1037
- isPlural: z.ZodBoolean;
1038
- }, z.core.$strip>;
2526
+ type UnpricedRunEstimate = TokenUnpricedRunEstimate | CharacterUnpricedRunEstimate;
1039
2527
  /**
1040
- * The validated shape of one translation unit; the inferred type of {@link translationEntrySchema},
1041
- * made deeply readonly. See that schema for what each field carries.
2528
+ * A pre-run estimate: what a run would send, and what that would cost at the rates the project
2529
+ * supplied. It is computed without constructing a provider, reading an API key, or making a network
2530
+ * call.
2531
+ *
2532
+ * It bounds the plan, not the invoice. Every provider call a live run schedules is counted,
2533
+ * including the plural-generation batches; the prompt is measured from the request payload that
2534
+ * would actually be sent rather than modelled; and the response is sized from the source value plus
2535
+ * an expansion allowance. Against that plan a live run usually spends less, because it consults the
2536
+ * translation memory and collapses identical source strings.
2537
+ *
2538
+ * It can also spend more, and {@link EstimateCaveatCode} names every way: the provider SDKs retry a
2539
+ * failed call underneath each planned request, an incomplete response triggers a repair round or a
2540
+ * recursive split, a target language can expand past the allowance, and the token figure is a
2541
+ * character heuristic rather than the provider's own tokenizer. Treat it as a planning figure, not
2542
+ * as a ceiling that cannot be crossed.
2543
+ *
2544
+ * It covers {@link translate} and nothing else. `retranslateEntry` constructs a provider and calls
2545
+ * it on its own path, reached from the Studio dashboard and the agent tools, and no estimate here
2546
+ * sees that spend.
2547
+ *
2548
+ * Branch on {@link pricing}: a {@link PricedRunEstimate} carries `cost`, `currency` and `asOf`, and
2549
+ * an {@link UnpricedRunEstimate} carries none of them and says why.
1042
2550
  */
1043
- type TranslationEntry = Readonly<z.infer<typeof translationEntrySchema>>;
1044
-
2551
+ type RunEstimate = PricedRunEstimate | UnpricedRunEstimate;
2552
+ /** A condition the SDK reported on a locale. See {@link SdkNoticeCode}. */
2553
+ interface SdkNotice {
2554
+ /** The stable notice code. Branch on this, not on the message. */
2555
+ readonly code: SdkNoticeCode;
2556
+ /** A human-readable description of the condition. */
2557
+ readonly message: string;
2558
+ }
1045
2559
  /**
1046
- * All translation entries for one locale and namespace, keyed by entry key. This is the neutral
1047
- * intermediate representation every adapter reads into and writes back out of, so nothing downstream
1048
- * of an adapter parses a format's own syntax.
2560
+ * Any notice attached to a locale: either one the provider raised, such as a downgraded formality
2561
+ * or an ignored glossary, or one the SDK raised. Discriminate on `code`.
1049
2562
  */
1050
- interface LocaleResource {
1051
- /** The locale these entries belong to (for example, "en" or "de"). */
1052
- readonly locale: string;
1053
- /** The namespace these entries belong to; empty when the format has no namespacing. */
1054
- readonly namespace: string;
1055
- /** The source format the resource came from, retained for round-trip fidelity on write. */
1056
- readonly format: SupportedFormat;
1057
- /** Entries addressable by key, in the order the source file defined them. */
1058
- readonly entries: ReadonlyMap<string, TranslationEntry>;
1059
- }
1060
-
2563
+ type LocaleNotice = ProviderNotice | SdkNotice;
1061
2564
  /**
1062
- * Result of comparing source placeholders against translated-output placeholders as MULTISETS
1063
- * (counts matter, so dropping one of two occurrences of the same token is still a miss). Always
1064
- * returned, never thrown: a mismatch is data the caller decides what to do about, not a failure.
2565
+ * A translated key the provider layer flagged as worth a human look. The translation was still
2566
+ * written; these are advisory quality signals, not rejections.
1065
2567
  */
1066
- interface PlaceholderIntegrityResult {
1067
- /** True when nothing is missing or extra: the multisets match, regardless of order. */
1068
- readonly matches: boolean;
1069
- /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
1070
- readonly missing: readonly string[];
1071
- /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
1072
- readonly extra: readonly string[];
1073
- /** Same multiset present in both, but in a different order. Never true alongside a mismatch. */
1074
- readonly reordered: boolean;
2568
+ interface NeedsReviewEntry {
2569
+ /** The key that was flagged. */
2570
+ readonly key: string;
2571
+ /** Why it was flagged. A key can carry more than one reason. */
2572
+ readonly reasons: readonly ReviewReasonCode[];
1075
2573
  }
1076
-
1077
2574
  /**
1078
- * The result of reading a file into core's intermediate representation. The two diagnostic lists are
1079
- * reported as data rather than thrown, so one bad leaf never fails a whole read.
2575
+ * A key whose translation was reused from the translation memory even though its source string had
2576
+ * changed, because the earlier source it was translated from is close enough to the current one.
2577
+ *
2578
+ * It is reported apart from {@link LocaleSummary.cacheHits} on purpose: an exact cache hit is the
2579
+ * same string it always was, while this is a translation of text that is no longer the source text.
2580
+ * {@link similarity} and {@link previousSource} are the evidence for the reuse, so a reader can
2581
+ * judge it rather than take it on trust.
2582
+ *
2583
+ * A reuse is written to the locale file but deliberately not locked: the key keeps the lock-file
2584
+ * baseline it already had, so it stays changed, is offered and flagged again on every later run at
2585
+ * no provider cost, and settles only once a real translation lands. Every key listed here also
2586
+ * carries `FUZZY_CACHE_REUSE` in {@link LocaleSummary.needsReview}.
1080
2587
  */
1081
- interface ReadResult {
1082
- /** The parsed locale resource. */
1083
- readonly resource: LocaleResource;
2588
+ interface FuzzyCacheHit {
2589
+ /** The key whose translation was reused. */
2590
+ readonly key: string;
2591
+ /** The key's source text at the time this translation was produced. */
2592
+ readonly previousSource: string;
1084
2593
  /**
1085
- * Keys whose values are invalid for the format's message syntax. Empty for a format with no
1086
- * message-syntax check, which treats every value as valid.
2594
+ * How alike {@link previousSource} and the current source are, from `0` to `1`, as measured by
2595
+ * the configured `fuzzyCache.threshold`. Always at or above that threshold.
1087
2596
  */
1088
- readonly invalidIcuKeys: readonly string[];
2597
+ readonly similarity: number;
2598
+ }
2599
+ /** A row of an imported handoff that could not be read. Reported rather than aborting the import. */
2600
+ interface MalformedRowReport {
2601
+ /** The row's 1-based index within its sheet or file. */
2602
+ readonly row: number;
1089
2603
  /**
1090
- * Dotted paths of leaves that were present in the source but excluded because they are not
1091
- * strings (a stray number, boolean, or null). These are never translated, hashed, diffed, or
1092
- * checked for placeholder or ICU integrity, and are not written back if the file is later
1093
- * rewritten. Empty for a file with no such leaves.
2604
+ * The 1-based physical line in the source file, for delimited formats where a quoted value can
2605
+ * span several lines. Absent for `.xlsx`, which has rows but no lines.
1094
2606
  */
1095
- readonly excludedLeafPaths: readonly string[];
2607
+ readonly line?: number;
2608
+ /** The column whose value was missing or unusable. */
2609
+ readonly column: string;
1096
2610
  }
1097
2611
  /**
1098
- * The contract every format adapter implements. A new format attaches by implementing this interface
1099
- * and registering it in an {@link AdapterRegistry}.
2612
+ * A key that appeared more than once in an imported handoff. The first occurrence wins and the rest
2613
+ * are reported here, so a translator who duplicated a row learns which value was actually used.
2614
+ */
2615
+ interface DuplicateKeyReport {
2616
+ /** The key that appeared more than once. */
2617
+ readonly key: string;
2618
+ /** The 1-based row index of the ignored occurrence. */
2619
+ readonly row: number;
2620
+ /** The 1-based physical line of the ignored occurrence, for delimited formats. Absent for `.xlsx`. */
2621
+ readonly line?: number;
2622
+ }
2623
+ /**
2624
+ * Everything that happened for one locale during a run. The key lists are disjoint accounts of what
2625
+ * became of each key, so a caller can reconstruct the whole run without re-reading any file.
1100
2626
  *
1101
- * Implement it through one of `@verbatra/format-adapters`' shared factories rather than from
1102
- * scratch: `createTreeFileAdapter` for a nested-tree format (with `createJsonFileAdapter` as its
1103
- * JSON specialization) and `createFlatFileAdapter` for a flat key/value format. A format that fits
1104
- * neither shape implements this interface directly, and either way it first needs its member added
1105
- * to core's {@link SupportedFormat}.
2627
+ * A locale that failed outright still appears here with `status: "failed"` and an `error`, rather
2628
+ * than the whole run throwing. That is the central contract of {@link translate}: one unreachable
2629
+ * provider or one unwritable file does not discard the locales that succeeded.
1106
2630
  */
1107
- interface FormatAdapter {
1108
- /** The single format this adapter handles (a {@link SupportedFormat} from core). */
1109
- readonly format: SupportedFormat;
2631
+ interface LocaleSummary {
2632
+ /** The target locale this summary describes. */
2633
+ readonly locale: string;
1110
2634
  /**
1111
- * Detect whether this adapter can handle a file, by path extension and an optional content sample.
1112
- * Best-effort and side-effect-free; it reads nothing from disk.
1113
- *
1114
- * @param filePath - The path of the file under consideration.
1115
- * @param sample - An optional leading content sample to aid detection.
1116
- * @returns True if this adapter could handle the file. Several adapters may return true for the same
1117
- * file (all JSON adapters claim `.json`); the caller disambiguates by explicit format.
2635
+ * `succeeded` when everything asked for was done, `partial` when some keys were translated and
2636
+ * others were not, and `failed` when the locale produced no usable result.
1118
2637
  */
1119
- canHandle(filePath: string, sample?: string): boolean;
2638
+ readonly status: "succeeded" | "partial" | "failed";
2639
+ /** Keys newly translated by the provider in this run. */
2640
+ readonly translated: readonly string[];
2641
+ /** Keys already up to date against the lock-file baseline, so no provider call was made. */
2642
+ readonly unchanged: readonly string[];
2643
+ /** Keys present in this locale but no longer in the source. Reported, and removed only when pruning. */
2644
+ readonly orphaned: readonly string[];
2645
+ /** Orphaned keys that were actually removed, which happens only when the run was asked to prune. */
2646
+ readonly pruned: readonly string[];
1120
2647
  /**
1121
- * Read a file into a {@link LocaleResource} plus its message-validity result.
1122
- *
1123
- * @param filePath - The file to read.
1124
- * @param locale - The locale to tag the resource with.
1125
- * @returns The parsed resource and the keys whose values are invalid for the format.
1126
- * @throws `AdapterError` when the content is malformed, oversized, or structurally invalid
1127
- * (the implementation names the specific codes). A missing or unopenable path instead rejects with
1128
- * the underlying filesystem error.
2648
+ * Keys whose source text is not valid ICU. They are skipped rather than sent to the provider,
2649
+ * because a broken source message cannot yield a sound translation.
1129
2650
  */
1130
- read(filePath: string, locale: string): Promise<ReadResult>;
2651
+ readonly invalidIcuSource: readonly string[];
1131
2652
  /**
1132
- * Write a {@link LocaleResource} back to a file, preserving key order and structure, atomically.
1133
- *
1134
- * @param resource - The resource to serialize.
1135
- * @param filePath - The destination file.
1136
- * @throws `AdapterError` if the resource cannot be represented in the format; rejects with the
1137
- * underlying filesystem error on a write failure.
2653
+ * Keys served from the translation memory instead of the provider, and so not paid for. Only
2654
+ * exact matches: a reuse for a source string that has since changed is reported in
2655
+ * {@link fuzzyHits} instead, never here.
1138
2656
  */
1139
- write(resource: LocaleResource, filePath: string): Promise<void>;
2657
+ readonly cacheHits: readonly string[];
1140
2658
  /**
1141
- * Extract the format's placeholder tokens from a single value, resolving nothing.
1142
- *
1143
- * @param value - The translatable string to scan.
1144
- * @returns The placeholder tokens found, in document order. Does not throw.
2659
+ * Keys whose translation was reused for a source string that had changed, with the score and the
2660
+ * earlier source behind each reuse. Always empty unless `fuzzyCache.enabled` was set. These keys
2661
+ * are written but not locked, so they reappear here on every run until they are really
2662
+ * translated. See {@link FuzzyCacheHit}.
1145
2663
  */
1146
- extractPlaceholders(value: string): readonly string[];
2664
+ readonly fuzzyHits: readonly FuzzyCacheHit[];
1147
2665
  /**
1148
- * Validate a single value against the format's message syntax before it is written. Only the
1149
- * ICU-message formats carry a real check today (next-intl and ARB); every other adapter has no
1150
- * message syntax to violate and returns true for any value.
1151
- *
1152
- * @param value - The candidate translated value to validate.
1153
- * @returns True when the value is valid for the format's message syntax. Does not throw; an
1154
- * unparseable value returns false.
2666
+ * Keys whose translation was refused by the integrity gate, for instance because it dropped a
2667
+ * placeholder. The previous translation, if any, is left untouched.
1155
2668
  */
1156
- validateMessage(value: string): boolean;
2669
+ readonly integrityMismatches: readonly string[];
2670
+ /** Keys the provider failed to translate, for instance because their sub-batch errored. */
2671
+ readonly providerFailures: readonly string[];
1157
2672
  /**
1158
- * Optional whole-value placeholder comparison, used by callers instead of independently extracting
1159
- * each side's placeholders with {@link extractPlaceholders} and diffing the flat lists. Two adapter
1160
- * families define one, for different reasons:
1161
- *
1162
- * - The ICU formats (next-intl and ARB) compare branch by branch, because flattening a
1163
- * plural/select value loses which branch a placeholder came from.
1164
- * - The double-brace formats (i18next, ngx-translate, and YAML) add a one-directional
1165
- * single-brace check on top of their flat multiset comparison: a `{name}`-shaped token present
1166
- * in the translated value but absent from the source is reported as `extra`. Single-brace text
1167
- * is ordinary literal content under these formats' default delimiters, so it is never required
1168
- * to survive a translation, but inventing one is never a legitimate translation either.
1169
- *
1170
- * Absent for the remaining adapters, which are compared via `extractPlaceholders` plus a flat
1171
- * multiset check.
1172
- *
1173
- * @param sourceValue - The source value.
1174
- * @param targetValue - The translated value to check against it.
1175
- * @returns The merged placeholder-integrity result. Does not throw.
2673
+ * Keys whose plural categories were generated for the target language rather than translated one
2674
+ * by one. On a dry run, the forms a live run would generate: generation is a provider call of
2675
+ * its own, so a dry run reports it rather than reporting nothing.
1176
2676
  */
1177
- comparePlaceholders?(sourceValue: string, targetValue: string): PlaceholderIntegrityResult;
1178
- }
1179
-
1180
- /**
1181
- * Outcome of resolving an adapter for a file. Structured, never thrown: an unresolvable file is a
1182
- * status the caller branches on, not an exception.
1183
- *
1184
- * `resolved` carries the single {@link FormatAdapter} to use. `no-match` means nothing claimed the
1185
- * file, and reports the formats that were tried (every registered format for a detection attempt, or
1186
- * just the one requested when an explicit format was given). `ambiguous` means more than one adapter
1187
- * claimed the file, which is routine since every JSON adapter claims `.json`; it reports the competing
1188
- * candidates instead of guessing, and the caller resolves it by naming a format explicitly.
1189
- */
1190
- type AdapterResolution = {
1191
- /** Exactly one adapter claimed the file. */
1192
- readonly status: "resolved";
1193
- /** The adapter to read and write the file with. */
1194
- readonly adapter: FormatAdapter;
1195
- } | {
1196
- /** No registered adapter claimed the file. */
1197
- readonly status: "no-match";
1198
- /** The file that could not be matched. */
1199
- readonly filePath: string;
2677
+ readonly generated: readonly string[];
2678
+ /** Keys not translated because the token budget was exhausted under `stop` behavior. */
2679
+ readonly budgetWithheld: readonly string[];
2680
+ /** Token usage for this locale. Absent when the provider does not report usage. */
2681
+ readonly usage?: UsageSummary;
2682
+ /** Provider and SDK notices raised while running this locale. Always present, possibly empty. */
2683
+ readonly notices: readonly LocaleNotice[];
2684
+ /** Translated keys flagged as worth a human look. The translations were still written. */
2685
+ readonly needsReview: readonly NeedsReviewEntry[];
2686
+ /** Keys left with no translation after the run, whatever the cause. */
2687
+ readonly unfilled: readonly string[];
2688
+ /** Unreadable rows from an imported handoff. Always empty for a {@link translate} run. */
2689
+ readonly malformedRows: readonly MalformedRowReport[];
2690
+ /** Repeated keys from an imported handoff. Always empty for a {@link translate} run. */
2691
+ readonly duplicateKeys: readonly DuplicateKeyReport[];
1200
2692
  /**
1201
- * The formats that were tried: every registered format for a detection attempt, or just the
1202
- * one requested when an explicit format was given.
2693
+ * Why this locale failed, when the failure came from a thrown error. The `code` is the failure's
2694
+ * own code where it has one, and `LOCALE_FAILED` otherwise. Never carries a secret.
2695
+ *
2696
+ * Absent unless `status` is `failed`, but a `failed` locale does not always carry it: a locale
2697
+ * whose every key was withheld by the integrity gate, a provider failure, or the token budget is
2698
+ * `failed` with nothing thrown, so this stays undefined and the withheld keys are the account of
2699
+ * what went wrong. Treat it as an optional detail on a failure, never as the failure test.
1203
2700
  */
1204
- readonly triedFormats: readonly SupportedFormat[];
1205
- } | {
1206
- /** More than one adapter claimed the file, so detection cannot choose between them. */
1207
- readonly status: "ambiguous";
1208
- /** The file that several adapters claimed. */
1209
- readonly filePath: string;
1210
- /** The competing formats. Resolve the ambiguity by naming one of them explicitly. */
1211
- readonly candidates: readonly SupportedFormat[];
1212
- };
1213
- /** Options for {@link AdapterRegistry.resolve}: select a format explicitly, or aid detection. */
1214
- interface ResolveOptions {
1215
- /** A leading content sample to aid detection. Ignored when `format` is given. */
1216
- readonly sample?: string;
1217
- /** Bypass detection and select this format explicitly. */
1218
- readonly format?: SupportedFormat;
2701
+ readonly error?: {
2702
+ /** The failure's own code where it had one, and `LOCALE_FAILED` otherwise. */
2703
+ readonly code: string;
2704
+ /** A human-readable description of the failure. Never contains a secret. */
2705
+ readonly message: string;
2706
+ };
1219
2707
  }
1220
2708
  /**
1221
- * Holds the registered adapters and resolves one for a file. Open for extension: adapters attach
1222
- * through {@link AdapterRegistry.register} without changing resolution logic, and resolution never
1223
- * throws.
2709
+ * The result of a whole run, returned by {@link translate}, {@link watch}, and
2710
+ * {@link importWorkbook}.
2711
+ *
2712
+ * Per-locale outcomes are data, not exceptions: inspect `failed` and `partial` to decide an exit
2713
+ * code rather than relying on the call to throw. Only whole-run failures, such as an invalid config
2714
+ * or an unreadable source file, throw an {@link SdkError}.
1224
2715
  */
1225
- declare class AdapterRegistry {
1226
- private readonly adapters;
2716
+ interface RunSummary {
2717
+ /** True when the run computed everything but wrote nothing and called no provider. */
2718
+ readonly dryRun: boolean;
1227
2719
  /**
1228
- * Register an adapter. Registration order is the order detection consults them, and a format may be
1229
- * registered more than once, in which case an explicit-format lookup takes the first.
1230
- *
1231
- * @param adapter - The adapter to add.
1232
- * @returns This registry, for chaining.
2720
+ * The full per-locale account. A {@link translate} or {@link watch} run reports them in
2721
+ * configured target order, whatever order a `locales` subset was given in and whatever order
2722
+ * concurrent locales finished in. An {@link importWorkbook} run does not: it reports them in the
2723
+ * order the handoff yielded them, then appends the configured locales the handoff had nothing
2724
+ * for. Match an entry on its `locale` rather than on its position.
1233
2725
  */
1234
- register(adapter: FormatAdapter): this;
1235
- private formats;
1236
- private resolveByFormat;
1237
- private resolveByDetection;
2726
+ readonly locales: readonly LocaleSummary[];
2727
+ /** Names of the locales whose status is `succeeded`. */
2728
+ readonly succeeded: readonly string[];
1238
2729
  /**
1239
- * Resolve the adapter for a file, by explicit format when given, otherwise by detection.
1240
- *
1241
- * @param filePath - The file to resolve an adapter for.
1242
- * @param options - `format` selects explicitly and skips detection; `sample` aids detection.
1243
- * @returns A structured {@link AdapterResolution}: `resolved`, `no-match`, or `ambiguous`. Never
1244
- * throws; an unresolvable file is a status, not an exception.
2730
+ * Names of the locales whose status is `partial`: written to disk, but with keys still missing.
2731
+ * Check this alongside `failed` before treating a run as clean. The CLI exits `1` on a partial
2732
+ * locale for that reason.
1245
2733
  */
1246
- resolve(filePath: string, options?: ResolveOptions): AdapterResolution;
2734
+ readonly partial: readonly string[];
2735
+ /** Names of the locales whose status is `failed`. Check this before treating a run as clean. */
2736
+ readonly failed: readonly string[];
2737
+ /** Token usage summed across every locale. Absent when the provider does not report usage. */
2738
+ readonly usage?: UsageSummary;
2739
+ /** The token budget in force, present only when the config set one. */
2740
+ readonly budget?: RunBudget;
2741
+ /**
2742
+ * What the run would have cost, present only when it was asked for a pre-run estimate. Always a
2743
+ * dry run: an estimate constructs no provider and spends nothing.
2744
+ */
2745
+ readonly estimate?: RunEstimate;
1247
2746
  }
1248
2747
 
2748
+ /**
2749
+ * Where a finished run stands against its token budget.
2750
+ *
2751
+ * - `within`: the run never reached its ceiling.
2752
+ * - `stopped-before-ceiling`: under `stop`, a request was withheld because sending it would have
2753
+ * passed the ceiling, while the counted total was still below it.
2754
+ * - `reached`: the counted total reached or passed the ceiling.
2755
+ */
2756
+ type BudgetStanding = "within" | "stopped-before-ceiling" | "reached";
2757
+ /**
2758
+ * Classifies a {@link RunBudget} into its {@link BudgetStanding}, so every surface that reports a
2759
+ * budget tells a `stop` run halted short of its ceiling apart from one that actually reached it.
2760
+ */
2761
+ declare function budgetStanding(budget: RunBudget): BudgetStanding;
2762
+
1249
2763
  /** One locale's counts in a {@link CheckSummary}. */
1250
2764
  interface LocaleCheckSummary {
1251
2765
  /** The target locale these counts describe. */
@@ -1258,6 +2772,12 @@ interface LocaleCheckSummary {
1258
2772
  readonly upToDate: number;
1259
2773
  /** True when this locale has nothing missing and nothing stale. */
1260
2774
  readonly inSync: boolean;
2775
+ /**
2776
+ * Every source string this locale translates more than one way under different keys, present only
2777
+ * when {@link CheckInput.consistency} is true (an empty array then means the locale is
2778
+ * consistent). This is a report and nothing more: it never changes `inSync` or any count.
2779
+ */
2780
+ readonly inconsistencies?: readonly InconsistencyGroup[];
1261
2781
  }
1262
2782
  /** The result of {@link check}: per-locale counts plus one project-wide verdict. */
1263
2783
  interface CheckSummary {
@@ -1274,6 +2794,11 @@ interface CheckInput {
1274
2794
  readonly cwd?: string;
1275
2795
  /** Restrict the report to these target locales. Defaults to every configured target locale. */
1276
2796
  readonly locales?: readonly string[];
2797
+ /**
2798
+ * Also report, per locale, every source string translated more than one way under different keys
2799
+ * (see {@link LocaleCheckSummary.inconsistencies}). Defaults to false.
2800
+ */
2801
+ readonly consistency?: boolean;
1277
2802
  }
1278
2803
  /** Injectable dependencies for {@link check}. Every field has a working default. */
1279
2804
  interface CheckDeps {
@@ -1294,6 +2819,16 @@ interface CheckDeps {
1294
2819
  *
1295
2820
  * Use {@link diff} instead when you need the key names rather than the counts.
1296
2821
  *
2822
+ * With {@link CheckInput.consistency} set, each locale also lists the source strings it translates
2823
+ * more than one way under different keys. Only keys that are up to date are compared, since a stale
2824
+ * translation belongs to an older source text. Values are compared after Unicode NFC normalization,
2825
+ * folding line endings, and trimming leading and trailing whitespace; internal whitespace and letter
2826
+ * case count. Keys whose description, meaning, plural flag, or gettext `msgctxt` differ are never
2827
+ * grouped, and neither are the different plural forms of one key: a format that stores each form
2828
+ * under its own key (i18next, Apple `.stringsdict` and `.xcstrings`, Android, gettext) is compared
2829
+ * per plural category or gettext `msgstr` index. The report never affects `inSync`, a count, or any
2830
+ * file, and a translation identical to its own source is not a finding here.
2831
+ *
1297
2832
  * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1298
2833
  * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1299
2834
  * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
@@ -1315,6 +2850,121 @@ interface CheckDeps {
1315
2850
  */
1316
2851
  declare function check(input: CheckInput, deps?: CheckDeps): Promise<CheckSummary>;
1317
2852
 
2853
+ /**
2854
+ * Why an unused-key report could not be produced at all.
2855
+ *
2856
+ * - `EXTRACT_NOT_CONFIGURED`: the config carries no `extract` block, so there is no source to scan.
2857
+ * - `EXTRACT_FS_UNSUPPORTED`: the supplied file system implements no `readDirectory`.
2858
+ * - `FRAMEWORK_NOT_MODELED`: the configured format is loaded by a runtime (next-intl, Vue I18n, or
2859
+ * ngx-translate) whose key lookups the i18next source scan does not model.
2860
+ * - `NO_SOURCE_FILES`: there is no source file under the configured roots at all.
2861
+ * - `NO_REFERENCES_FOUND`: the scanned files name no translation key and hold no dynamic key site,
2862
+ * while the catalog is not empty, so the scan most likely looked at the wrong code.
2863
+ */
2864
+ type UnusedKeysNotRunReason = "EXTRACT_NOT_CONFIGURED" | "EXTRACT_FS_UNSUPPORTED" | "FRAMEWORK_NOT_MODELED" | "NO_SOURCE_FILES" | "NO_REFERENCES_FOUND";
2865
+ /**
2866
+ * Why a completed scan cannot rule a key unused with confidence.
2867
+ *
2868
+ * - `dynamic-keys`: a key argument has no static part (`t(key)`, a template literal with no static
2869
+ * head, a concatenation), so any catalog key could be the one it reaches.
2870
+ * - `dynamic-key-prefix`: a `keyPrefix` option or a `getFixedT` prefix argument is not a static
2871
+ * string, so every key called under it is unknown.
2872
+ * - `trans-without-key`: a `Trans` element carries no static `i18nKey`.
2873
+ * - `aliased-translate-function`: the translate function is assigned somewhere its calls cannot be
2874
+ * followed, such as an object property.
2875
+ * - `translate-function-escapes`: a translate function bound from a recognised source appears
2876
+ * somewhere other than a direct call, its own binding, a local alias, or the `t` attribute of a
2877
+ * `Trans` or `Translation` element, so code the scan cannot follow may call it with any key.
2878
+ * - `unrecognised-translate-source`: `useTranslation`, `withTranslation`, `getFixedT`, a
2879
+ * `Translation` render prop, a member `t`, or a `t` import is used in a shape the scan does not
2880
+ * recognise, so the prefix and keys of the function it yields are unknown.
2881
+ * - `template-files-not-scanned`: the roots hold template files (`.vue`, `.svelte`, `.html`, and
2882
+ * similar) the scan does not read.
2883
+ * - `incomplete-scan`: a file or directory could not be read to its end.
2884
+ */
2885
+ type UnusedKeysUnreliableReason = "dynamic-keys" | "dynamic-key-prefix" | "trans-without-key" | "aliased-translate-function" | "translate-function-escapes" | "unrecognised-translate-source" | "template-files-not-scanned" | "incomplete-scan";
2886
+ /** One place that makes an unused-key report unreliable. */
2887
+ interface UnusedKeysSite {
2888
+ /** The file or directory, relative to the run's working directory, with forward slashes. */
2889
+ readonly file: string;
2890
+ /** The one-based line, when the reason points at a line. */
2891
+ readonly line?: number;
2892
+ /** For `incomplete-scan`, why the file was skipped (for example `too-large`). */
2893
+ readonly detail?: string;
2894
+ }
2895
+ /** One reason an unused-key report is unreliable, with the places that caused it. */
2896
+ interface UnusedKeysUnreliability {
2897
+ /** The stable reason code. */
2898
+ readonly reason: UnusedKeysUnreliableReason;
2899
+ /** How many places caused it. For template files this can exceed the listed `sites`. */
2900
+ readonly count: number;
2901
+ /**
2902
+ * The places, deduplicated on file and line. Every place is listed, except template files, which
2903
+ * are capped at the first 20.
2904
+ */
2905
+ readonly sites: readonly UnusedKeysSite[];
2906
+ }
2907
+ /**
2908
+ * A source-catalog key in an unused-key report. `key` is the key as a person and the source spell
2909
+ * it, with the format's internal encoding undone (`Welcome. Enjoy` rather than `Welcome\. Enjoy`,
2910
+ * a gettext `msgctxt` shown as `context|msgid`); `extract.unused.ignore` patterns match it.
2911
+ * `catalogKey` is the key exactly as the catalog adapter and the per-locale `orphaned` lists spell
2912
+ * it, for a tool that needs to address the entry.
2913
+ */
2914
+ interface UnusedKey {
2915
+ /** The decoded key, as shown to people and matched by `extract.unused.ignore`. */
2916
+ readonly key: string;
2917
+ /** The key as the catalog adapter encodes it. */
2918
+ readonly catalogKey: string;
2919
+ }
2920
+ /** A source-catalog key no static reference names, but a template-literal key could still reach. */
2921
+ interface PossiblyDynamicKey extends UnusedKey {
2922
+ /** The static head of the template-literal key that could reach it. */
2923
+ readonly prefix: string;
2924
+ }
2925
+ /** A template-literal key argument with a static head, located in the source. */
2926
+ interface UnusedKeysPrefixSite extends SourceLocation {
2927
+ /** The static head every key the site can reach starts with. */
2928
+ readonly prefix: string;
2929
+ }
2930
+ /** An unused-key report produced from a source scan. */
2931
+ interface UnusedKeysScan {
2932
+ /**
2933
+ * `complete` when nothing in the scan casts doubt on the verdict; `unreliable` when
2934
+ * {@link UnusedKeysScan.unreliableBecause} is not empty, so a listed key may still be in use.
2935
+ */
2936
+ readonly status: "complete" | "unreliable";
2937
+ /** Every reason the verdict cannot be trusted. Empty exactly when `status` is `complete`. */
2938
+ readonly unreliableBecause: readonly UnusedKeysUnreliability[];
2939
+ /** How many source files were read and scanned. */
2940
+ readonly scannedFiles: number;
2941
+ /** Source-catalog keys no scanned reference names, in catalog order. */
2942
+ readonly unused: readonly UnusedKey[];
2943
+ /**
2944
+ * Unreferenced source-catalog keys a template-literal key with a static head could reach, in
2945
+ * catalog order. They are listed apart from `unused` and never fail a run.
2946
+ */
2947
+ readonly possiblyDynamic: readonly PossiblyDynamicKey[];
2948
+ /** Unreferenced source-catalog keys matched by `extract.unused.ignore`, in catalog order. */
2949
+ readonly ignored: readonly UnusedKey[];
2950
+ /** The template-literal key sites behind `possiblyDynamic`. */
2951
+ readonly dynamicPrefixes: readonly UnusedKeysPrefixSite[];
2952
+ }
2953
+ /**
2954
+ * The source-locale catalog keys that nothing in the scanned source references. Distinct from the
2955
+ * per-locale `orphaned` lists, which compare a target locale against the source catalog.
2956
+ */
2957
+ type UnusedKeysReport = UnusedKeysNotRun | UnusedKeysScan;
2958
+ /** An unused-key report that could not run. It carries no key list, so nothing is misreported. */
2959
+ interface UnusedKeysNotRun {
2960
+ /** Always `not-run`. */
2961
+ readonly status: "not-run";
2962
+ /** The stable reason code. Branch on this, not on the message. */
2963
+ readonly reason: UnusedKeysNotRunReason;
2964
+ /** A human-readable explanation. */
2965
+ readonly message: string;
2966
+ }
2967
+
1318
2968
  /** One locale's pending work in a {@link DiffSummary}, as key names rather than counts. */
1319
2969
  interface LocaleDiff {
1320
2970
  /** The target locale this entry describes. */
@@ -1340,22 +2990,45 @@ interface DiffSummary {
1340
2990
  readonly hasPendingChanges: boolean;
1341
2991
  /** Per-locale key lists, in configured target order. */
1342
2992
  readonly locales: readonly LocaleDiff[];
2993
+ /**
2994
+ * Source-catalog keys nothing in the scanned application source references. Present only when
2995
+ * {@link DiffInput.unused} was set. It is a separate axis from each locale's `orphaned` list and
2996
+ * never flips {@link DiffSummary.hasPendingChanges}.
2997
+ */
2998
+ readonly unused?: UnusedKeysReport;
1343
2999
  }
1344
3000
  /** Input for {@link diff}. */
1345
3001
  interface DiffInput {
1346
3002
  /** The resolved project config, normally from {@link loadConfig}. */
1347
3003
  readonly config: VerbatraConfig;
1348
- /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
3004
+ /**
3005
+ * Directory the `files.pattern` is resolved against. Defaults to the process working directory.
3006
+ */
1349
3007
  readonly cwd?: string;
1350
3008
  /** Restrict the report to these target locales. Defaults to every configured target locale. */
1351
3009
  readonly locales?: readonly string[];
3010
+ /**
3011
+ * Also scan the application source configured in the `extract` block and report the
3012
+ * source-catalog keys nothing references, as {@link DiffSummary.unused}. Off by default.
3013
+ */
3014
+ readonly unused?: boolean;
1352
3015
  }
1353
3016
  /** Injectable dependencies for {@link diff}. Every field has a working default. */
1354
3017
  interface DiffDeps {
1355
- /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
3018
+ /**
3019
+ * Format-adapter registry to resolve the configured format. Defaults to the built-in registry.
3020
+ */
1356
3021
  readonly adapterRegistry?: AdapterRegistry;
1357
- /** File-system port. Defaults to the real file system. */
3022
+ /**
3023
+ * File-system port. Defaults to the real file system. The unused-key scan needs its
3024
+ * `readDirectory` member and reports its absence as `EXTRACT_FS_UNSUPPORTED`.
3025
+ */
1358
3026
  readonly fs?: SdkFs;
3027
+ /**
3028
+ * Extractor factory for the unused-key scan. Defaults to the built-in table keyed by the
3029
+ * configured framework.
3030
+ */
3031
+ readonly createExtractor?: (framework: SourceFramework) => SourceExtractor;
1359
3032
  }
1360
3033
  /**
1361
3034
  * Reports the per-locale drift between the source and each target locale as key names, without
@@ -1366,19 +3039,34 @@ interface DiffDeps {
1366
3039
  * since the key was last translated rather than merely that the two strings differ. Orphaned keys
1367
3040
  * are reported but never removed here; pruning happens only in {@link translate}.
1368
3041
  *
3042
+ * With `unused` set, it also scans the application source named by the config's `extract` block
3043
+ * and reports, in {@link DiffSummary.unused}, the source-catalog keys no static reference names.
3044
+ * The scan models the i18next runtime: `t` calls and their aliases, `keyPrefix` and `getFixedT`
3045
+ * prefixes, `Trans` keys, namespace-qualified keys, and the plural and context variants of a
3046
+ * referenced key. It is read-only like the rest of `diff`: it never removes, rewrites, or reorders
3047
+ * a catalog, and it constructs no provider and reads no API key. Keys matched by
3048
+ * `extract.unused.ignore` are listed as `ignored`, and keys a template-literal key with a static
3049
+ * head could reach as `possiblyDynamic`, never as `unused`. The report is `complete` only when the
3050
+ * scan can bound every key the source reaches; otherwise it is `unreliable` and names each reason
3051
+ * with its sites, or `not-run` with a reason code and no key list at all when there is nothing
3052
+ * trustworthy to judge by (no `extract` block, a format whose runtime is not modeled, no source
3053
+ * file, or no reference anywhere in the scanned files).
3054
+ *
1369
3055
  * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1370
3056
  * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
1371
3057
  * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
1372
3058
  * error from a target file.
1373
3059
  *
1374
- * @param input - The config and the optional locale filter.
1375
- * @param deps - Optional adapter registry and file-system overrides.
1376
- * @returns Per-locale missing, changed, and orphaned key lists.
3060
+ * @param input - The config, the optional locale filter, and whether to report unused keys.
3061
+ * @param deps - Optional adapter registry, file-system, and extractor-factory overrides.
3062
+ * @returns Per-locale missing, changed, and orphaned key lists, plus the unused-key report when
3063
+ * asked for.
1377
3064
  *
1378
3065
  * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1379
3066
  * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1380
3067
  * cannot be combined, or a configured locale has no valid path spelling under that style.
1381
- * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
3068
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same
3069
+ * path.
1382
3070
  * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1383
3071
  * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1384
3072
  * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
@@ -1396,8 +3084,11 @@ declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
1396
3084
  * - `api-key`: the environment variable the configured provider reads its key from is set.
1397
3085
  * - `source-file`: the source locale file exists at its resolved path, is a regular file, and
1398
3086
  * parses under the configured format.
3087
+ * - `untranslated-literals`: the application source configured in the `extract` block holds no
3088
+ * hardcoded user-facing string literal and no file the scan could not read. It runs only when
3089
+ * {@link DoctorInput.literals} is set.
1399
3090
  */
1400
- type DoctorCheckId = "config" | "format-adapter" | "provider" | "api-key" | "source-file";
3091
+ type DoctorCheckId = "config" | "format-adapter" | "provider" | "api-key" | "source-file" | "untranslated-literals";
1401
3092
  /**
1402
3093
  * The verdict on one {@link DoctorCheck}. `skipped` is reported only for the checks that need a
1403
3094
  * loaded config when the `config` check itself failed, so a skipped check is never a problem of its
@@ -1423,8 +3114,18 @@ interface DoctorCheck {
1423
3114
  interface DoctorResult {
1424
3115
  /** True only when no check failed. This is the value a script should branch on. */
1425
3116
  readonly ok: boolean;
1426
- /** Every check, always in the same order, one entry per {@link DoctorCheckId}. */
3117
+ /**
3118
+ * Every check that ran, always in the same order. A setup run has one entry per setup check:
3119
+ * `config`, `format-adapter`, `provider`, `api-key`, and `source-file`. A literal run
3120
+ * ({@link DoctorInput.literals}) has exactly two: `config` and `untranslated-literals`.
3121
+ */
1427
3122
  readonly checks: readonly DoctorCheck[];
3123
+ /**
3124
+ * What the untranslated-literal scan found. Present only on a literal run whose scan actually
3125
+ * ran; absent when the config could not be loaded, no `extract` block is configured, or the
3126
+ * file system cannot list directories.
3127
+ */
3128
+ readonly literals?: LiteralScan;
1428
3129
  }
1429
3130
  /** Input for {@link doctor}. */
1430
3131
  interface DoctorInput {
@@ -1432,6 +3133,13 @@ interface DoctorInput {
1432
3133
  readonly cwd?: string;
1433
3134
  /** An explicit config file to validate, bypassing the search. A missing file is an error rather than a failed check. */
1434
3135
  readonly configPath?: string;
3136
+ /**
3137
+ * Run the untranslated-literal scan instead of the setup checks: the config is loaded, then the
3138
+ * source roots of its `extract` block are scanned for hardcoded user-facing string literals. The
3139
+ * provider, API-key, format, and source-file checks do not run, so no API key environment
3140
+ * variable is looked at and a run with no key set can pass.
3141
+ */
3142
+ readonly literals?: boolean;
1435
3143
  }
1436
3144
  /** Injectable dependencies for {@link doctor}. Every field has a working default. */
1437
3145
  interface DoctorDeps {
@@ -1476,7 +3184,18 @@ interface DoctorDeps {
1476
3184
  * verdict they could not reach, and {@link DoctorResult.ok} is false because the config check
1477
3185
  * itself failed.
1478
3186
  *
1479
- * @param input - The working directory and an optional explicit config path.
3187
+ * With `literals: true` it runs the untranslated-literal scan instead of the setup checks. The
3188
+ * source roots of the config's `extract` block are read, never written, and every string literal
3189
+ * or piece of JSX text that reads as user-facing text without going through a recognised
3190
+ * translation call is returned in {@link DoctorResult.literals} with its file, line, column, and
3191
+ * an excerpt of at most 80 characters. Literals held back by a `verbatra-ignore-next-line` or
3192
+ * `verbatra-ignore-line` comment, or by `extract.literals.ignore`, are returned under `suppressed`
3193
+ * rather than dropped. The check fails when anything was found or when a file could not be
3194
+ * scanned, so a partial scan is never reported as clean. No provider is constructed and no API key
3195
+ * environment variable is read.
3196
+ *
3197
+ * @param input - The working directory, an optional explicit config path, and whether to run the
3198
+ * untranslated-literal scan instead of the setup checks.
1480
3199
  * @param deps - Optional adapter registry, file-system, and config-loader overrides.
1481
3200
  * @returns Every check with its verdict, and the project-wide `ok` verdict.
1482
3201
  *
@@ -1497,7 +3216,7 @@ interface DoctorDeps {
1497
3216
  declare function doctor(input?: DoctorInput, deps?: DoctorDeps): Promise<DoctorResult>;
1498
3217
 
1499
3218
  /**
1500
- * Why a candidate translation was refused before it could be written. The same gate guards
3219
+ * Every reason a candidate translation can be refused before it is written. The same gate guards
1501
3220
  * {@link editEntry} and {@link retranslateEntry}, so a hand-typed value and a provider-produced one
1502
3221
  * are held to identical standards.
1503
3222
  *
@@ -1508,6 +3227,48 @@ declare function doctor(input?: DoctorInput, deps?: DoctorDeps): Promise<DoctorR
1508
3227
  * interpolation would break at runtime. For the double-brace formats (i18next, ngx-translate, and
1509
3228
  * YAML) this also covers a single-brace `{name}`-shaped token the candidate invented and the
1510
3229
  * source never had, which is a fabrication whichever interpolation delimiters the project uses.
3230
+ * - `markup`: the candidate does not carry the same inline HTML or XML tags as the source, or it
3231
+ * carries them unbalanced, mis-nested, or newly nested inside another tag of the same name, any
3232
+ * of which breaks the rendering of the string the way a dropped placeholder breaks its
3233
+ * interpolation, or it would make an HTML parser create markup the source does not have. Tags
3234
+ * are read the way an HTML parser reads them (quoted attribute values may contain `>`, and the
3235
+ * content of a raw text element such as `script`, `style`, `title` or `textarea` is text up to
3236
+ * its closing tag) and compared as a multiset of names plus attribute names, so a different word
3237
+ * order and a translated attribute value are both accepted, and the two spellings of a void
3238
+ * element (`<br>` and `<br/>`) are one tag. Tag names are compared exactly, while the HTML void
3239
+ * elements are recognised as needing no closing tag in any case spelling. An unclosed bracketed
3240
+ * word that is only a name of letters, digits, hyphens, and underscores, such as `<Enter>`, is
3241
+ * set aside on both sides and counted like a tag once the source carries tags; where the source
3242
+ * has no tags, the candidate is refused for a closing tag with no opening tag, an unclosed tag
3243
+ * that carries anything beyond its name, any closed pair, void, or self-closing tag, and a
3244
+ * bracketed word the source does not carry that names a standard HTML element, `image`, or a
3245
+ * custom element (any name with a hyphen). Comments, CDATA sections, declarations, processing
3246
+ * instructions, and a `</` not followed by a letter end where an HTML parser ends them and are
3247
+ * compared as a multiset of their text, so one the candidate adds, drops, or rewrites is
3248
+ * refused. A source whose own tags are not well formed is still compared as a multiset, and no
3249
+ * source is too large to compare; a candidate is refused once it carries more than twice its
3250
+ * source's tags and constructs, or more than 256 for a smaller source. Also refused: a tag the
3251
+ * candidate never finishes when the source finishes all of its own; a `javascript:`,
3252
+ * `vbscript:`, or `data:` scheme in a URL attribute value, read after decoding character
3253
+ * references, unless the source carries that exact value on the same tag and attribute; a URL
3254
+ * in such a value whose scheme and authority no source value of the same tag and attribute has,
3255
+ * or a relative URL made absolute; a changed value of `meta` `http-equiv` or `content`, `script`
3256
+ * `type`, `nomodule`, `integrity` or `crossorigin`, `link` `rel`, `as`, `integrity`,
3257
+ * `crossorigin` or `type`, `base` `target`, SVG animation `attributeName` or `attributeType`,
3258
+ * `iframe` `sandbox`, `allow` or `allowfullscreen`, `form` `method`, `enctype` or `target`, or
3259
+ * any `style`, `srcdoc` or event handler; any change to the content of a `script`, `style`,
3260
+ * `iframe`, `noembed`, `noframes`, `noscript`, `xmp`, or `plaintext` element (`title` and
3261
+ * `textarea` content may be translated); and, unless the candidate is the source unchanged,
3262
+ * raw text an HTML parser can read two ways (a `script` whose content opens `<!--`, a `noscript`
3263
+ * whose content holds a `<`, a raw text element whose content holds a `<` after an `svg`,
3264
+ * `math`, or `select` tag) or a CDATA section or `image` tag in a value that opens `svg`, `math`,
3265
+ * or `select`. The check stands down per tag: a tag the format already reports as a placeholder
3266
+ * (an XLIFF inline element, a next-intl or ARB ICU rich-text tag) is left to the `placeholder`
3267
+ * reason together with as many closing tags as it has openings, while one the candidate leaves
3268
+ * unclosed, one that no longer nests with the other tags as in the source, and one the source
3269
+ * carries only as text are still refused, and any other tag in the same value is still
3270
+ * compared, including a second spelling of the same name. The refusal's `details` names the
3271
+ * offending tags.
1511
3272
  * - `icu`: the candidate is not a valid ICU message under the configured format's adapter.
1512
3273
  * - `degenerate`: the candidate collapsed into runaway output rather than a translation. Two shapes
1513
3274
  * are detected: the candidate is at least twelve times the length of a source of meaningful
@@ -1515,8 +3276,25 @@ declare function doctor(input?: DoctorInput, deps?: DoctorDeps): Promise<DoctorR
1515
3276
  * echo of the source is not degenerate by this rule; it surfaces as the `EQUALS_SOURCE` review
1516
3277
  * reason instead, which flags rather than refuses.
1517
3278
  * - `empty`: the source has text but the candidate is blank, which would silently erase a string.
3279
+ *
3280
+ * This tuple is the single source of truth for the set. {@link IntegrityGateReason} is derived from
3281
+ * it, so build any runtime validator or exhaustive lookup from this value rather than retyping the
3282
+ * members; a hand-copied list silently falls behind the next addition.
3283
+ *
3284
+ * @example
3285
+ * ```ts
3286
+ * import { INTEGRITY_GATE_REASONS } from "@verbatra/sdk";
3287
+ * import { z } from "zod";
3288
+ *
3289
+ * const reasonSchema = z.enum(INTEGRITY_GATE_REASONS);
3290
+ * ```
3291
+ */
3292
+ declare const INTEGRITY_GATE_REASONS: readonly ["placeholder", "markup", "icu", "degenerate", "empty"];
3293
+ /**
3294
+ * One of {@link INTEGRITY_GATE_REASONS}. The union is derived from that tuple rather than written
3295
+ * out again, so a reason can only be added in one place.
1518
3296
  */
1519
- type IntegrityGateReason = "placeholder" | "icu" | "degenerate" | "empty";
3297
+ type IntegrityGateReason = (typeof INTEGRITY_GATE_REASONS)[number];
1520
3298
 
1521
3299
  /** Input for {@link editEntry}. */
1522
3300
  interface EditEntryInput {
@@ -1553,6 +3331,14 @@ type EditEntryResult = {
1553
3331
  readonly accepted: false;
1554
3332
  /** Which integrity rule the value broke. */
1555
3333
  readonly reason: IntegrityGateReason;
3334
+ /**
3335
+ * The specific tags behind a `markup` refusal, each prefixed with `-` for one the source had
3336
+ * and the candidate dropped or `+` for one the candidate invented. A candidate carrying more
3337
+ * than twice its source's inline tags and constructs is named as `+more than N inline tags`
3338
+ * instead, where N is at least 256. Absent when no single tag is at fault, such as markup
3339
+ * that came back mis-nested, and absent for every other reason.
3340
+ */
3341
+ readonly details?: readonly string[];
1556
3342
  /** The rejected value, echoed back so a UI can show what was refused. */
1557
3343
  readonly value: string;
1558
3344
  };
@@ -1606,6 +3392,242 @@ type EditEntryResult = {
1606
3392
  */
1607
3393
  declare function editEntry(input: EditEntryInput, deps?: EditEntryDeps): Promise<EditEntryResult>;
1608
3394
 
3395
+ /** One key the run added to the source catalog, and the call site it was found at. */
3396
+ interface AddedKey {
3397
+ /** The key as written at the call site. */
3398
+ readonly key: string;
3399
+ /** The value written into the catalog: the call site's default, or the empty string when it had none. */
3400
+ readonly value: string;
3401
+ /** The source file the key was found in, relative to the run's working directory. */
3402
+ readonly file: string;
3403
+ /** The one-based line the key was found on. */
3404
+ readonly line: number;
3405
+ }
3406
+ /**
3407
+ * The result of {@link extract}. Everything it reports is a key, a value, or a file and line: a
3408
+ * source file's contents never travel in it.
3409
+ */
3410
+ interface ExtractResult {
3411
+ /** The source locale file the run targeted, relative to the run's working directory. */
3412
+ readonly sourcePath: string;
3413
+ /** How many source files were read and scanned. */
3414
+ readonly scannedFiles: number;
3415
+ /** Keys that were not in the catalog and were added, in the order they were found. */
3416
+ readonly added: readonly AddedKey[];
3417
+ /** How many of the keys found were already in the catalog and were left untouched. */
3418
+ readonly existingKeys: number;
3419
+ /** Added keys whose call site carried no default, so they were written with an empty value. */
3420
+ readonly withoutDefault: readonly string[];
3421
+ /** Call sites whose key argument was not a static string, reported rather than guessed at. */
3422
+ readonly dynamic: readonly SourceLocation[];
3423
+ /** Keys found twice with two different default values. Neither value is written. */
3424
+ readonly conflicts: readonly KeyConflict[];
3425
+ /** Files and directories the scan could not read, which never abort the run. */
3426
+ readonly diagnostics: readonly ScanDiagnostic[];
3427
+ /** Whether the source catalog was actually written. False for a dry run and for a run that added nothing. */
3428
+ readonly written: boolean;
3429
+ /** Whether this was a dry run. */
3430
+ readonly dryRun: boolean;
3431
+ }
3432
+ /** Input for {@link extract}. */
3433
+ interface ExtractInput {
3434
+ /** A resolved config whose `extract` block names the framework and the source roots. */
3435
+ readonly config: VerbatraConfig;
3436
+ /** Directory the roots and locale paths are resolved against. Defaults to the process working directory. */
3437
+ readonly cwd?: string;
3438
+ /** Report what would be added without writing anything. */
3439
+ readonly dryRun?: boolean;
3440
+ }
3441
+ /** Injectable dependencies for {@link extract}. Every field has a working default. */
3442
+ interface ExtractDeps {
3443
+ /** Format-adapter registry to resolve the configured format against. Defaults to the built-in registry. */
3444
+ readonly adapterRegistry?: AdapterRegistry;
3445
+ /**
3446
+ * File-system port. It backs both the source scan and the catalog write, so its `readDirectory`
3447
+ * member must be implemented. Defaults to the real file system.
3448
+ */
3449
+ readonly fs?: SdkFs;
3450
+ /** Extractor factory. Defaults to the built-in table keyed by the configured framework. */
3451
+ readonly createExtractor?: (framework: SourceFramework) => SourceExtractor;
3452
+ }
3453
+ /**
3454
+ * Scans a project's own source for translation call sites and merges what it finds into the source
3455
+ * locale catalog. It is the one entry point that reads application code rather than a locale file,
3456
+ * which is what makes verbatra usable on a project that has no catalog yet.
3457
+ *
3458
+ * It spends nothing: no provider is constructed, no API key environment variable is read, and no
3459
+ * network request is made. A run with no key set succeeds.
3460
+ *
3461
+ * Only the source locale file is ever written, and only keys that are genuinely new are added: a
3462
+ * key already in the catalog keeps its value exactly as it stands, so an editorial fix is never
3463
+ * reverted by a stale default left at a call site. A key in the catalog that no call site mentions
3464
+ * is left alone. A run that finds nothing new writes nothing at all, so it leaves the file
3465
+ * byte-identical and its modification time untouched.
3466
+ *
3467
+ * Everything the scan cannot resolve is reported as data rather than thrown. A call site whose key
3468
+ * argument is not a static string is reported in `dynamic` and never written with a guessed key; a
3469
+ * key found twice with two different defaults is reported in `conflicts` and neither value is
3470
+ * written; a file or directory that cannot be read is reported in `diagnostics` and the scan
3471
+ * carries on. The result never carries a source file's contents, only keys, values, and locations.
3472
+ *
3473
+ * @param input - The config (whose `extract` block names the framework and roots), the working
3474
+ * directory, and whether this is a dry run.
3475
+ * @param deps - Optional adapter registry, file-system, and extractor-factory overrides.
3476
+ * @returns What was added, what was already there, and everything the scan could not resolve.
3477
+ *
3478
+ * @throws {@link SdkError} `EXTRACT_NOT_CONFIGURED`: the config carries no `extract` block.
3479
+ * @throws {@link SdkError} `EXTRACT_FS_UNSUPPORTED`: the supplied `deps.fs` implements no
3480
+ * `readDirectory`, so no source file can be discovered.
3481
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
3482
+ * @throws {@link SdkError} `SOURCE_INVALID`: a source catalog exists but could not be parsed.
3483
+ * @throws {@link SdkError} `SOURCE_UNWRITABLE`: the source catalog could not be written. The
3484
+ * `xliff` and `apple-xcstrings` formats reach this when no catalog exists yet: neither is created
3485
+ * from nothing, here or anywhere else in verbatra.
3486
+ *
3487
+ * @example
3488
+ * ```ts
3489
+ * import { extract, loadConfig } from "@verbatra/sdk";
3490
+ *
3491
+ * const config = await loadConfig();
3492
+ * const result = await extract({ config, dryRun: true });
3493
+ * console.log(`${result.added.length} new keys, ${result.dynamic.length} dynamic call sites`);
3494
+ * ```
3495
+ */
3496
+ declare function extract(input: ExtractInput, deps?: ExtractDeps): Promise<ExtractResult>;
3497
+
3498
+ /** Why verbatra declined to describe a message's arguments rather than guessing at them. */
3499
+ type UnresolvedArgumentReason = "invalid-message-syntax" | "mixed-argument-styles" | "argument-index-out-of-range";
3500
+
3501
+ /**
3502
+ * Where {@link generateTypes} writes its declaration when the caller names no path: a `.d.ts` at
3503
+ * the root of the working directory. The file is a checked-in artifact, not a local scratch file:
3504
+ * it belongs in version control so a consumer type-checks against it without running verbatra
3505
+ * first, and so `check` mode has a committed file to compare against.
3506
+ */
3507
+ declare const DEFAULT_TYPES_PATH = "verbatra-types.d.ts";
3508
+ /** One key whose arguments verbatra declined to describe, and why. */
3509
+ interface UnresolvedMessage {
3510
+ /** The key, which is still declared, with arguments nothing is claimed about. */
3511
+ readonly key: string;
3512
+ /** Why the arguments could not be determined. */
3513
+ readonly reason: UnresolvedArgumentReason;
3514
+ }
3515
+ /** Input for {@link generateTypes}. */
3516
+ interface GenerateTypesInput {
3517
+ /** The resolved project config, normally from {@link loadConfig}. */
3518
+ readonly config: VerbatraConfig;
3519
+ /** Directory the `files.pattern` and the output path are resolved against. Defaults to the process working directory. */
3520
+ readonly cwd?: string;
3521
+ /**
3522
+ * Where to write the declaration, relative to `cwd`. Defaults to {@link DEFAULT_TYPES_PATH}.
3523
+ * Refused with `TYPES_OUTPUT_CONFLICT`, before anything is read or written, when it names no
3524
+ * file, is absolute, climbs out of `cwd`, does not end in `.ts`, `.mts` or `.cts`, or names a
3525
+ * configured locale file, the lock file, the translation-memory cache, a file verbatra searches
3526
+ * for its configuration, or the {@link GenerateTypesInput.configPath} file. Names are compared
3527
+ * case-insensitively. A generating run also refuses to replace an existing file there unless
3528
+ * that file begins with the header line verbatra writes, after any leading byte order mark and
3529
+ * blank lines, and refuses one too large to verify.
3530
+ */
3531
+ readonly out?: string;
3532
+ /**
3533
+ * The configuration file `config` was loaded from, absolute or relative to `cwd`. It is refused
3534
+ * as the output path even when its name is not one verbatra searches for.
3535
+ */
3536
+ readonly configPath?: string;
3537
+ /**
3538
+ * Compare instead of writing. The run reports whether the file on disk matches what a fresh
3539
+ * generation would produce and leaves every file untouched.
3540
+ */
3541
+ readonly check?: boolean;
3542
+ }
3543
+ /** Injectable dependencies for {@link generateTypes}. Every field has a working default. */
3544
+ interface GenerateTypesDeps {
3545
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
3546
+ readonly adapterRegistry?: AdapterRegistry;
3547
+ /** File-system port. Defaults to the real file system. */
3548
+ readonly fs?: SdkFs;
3549
+ }
3550
+ /** The result of {@link generateTypes}: what was declared, and whether the file on disk matched. */
3551
+ interface GenerateTypesResult {
3552
+ /** Absolute path of the declaration file. */
3553
+ readonly path: string;
3554
+ /** The source catalog the declaration was built from, relative to the working directory. */
3555
+ readonly sourcePath: string;
3556
+ /** How many keys the declaration carries. */
3557
+ readonly keys: number;
3558
+ /**
3559
+ * How many of those keys take at least one argument verbatra could determine. A key listed in
3560
+ * {@link GenerateTypesResult.unresolved} is not counted here.
3561
+ */
3562
+ readonly withArguments: number;
3563
+ /** Keys that are declared but whose arguments could not be determined, and why. */
3564
+ readonly unresolved: readonly UnresolvedMessage[];
3565
+ /** Keys the adapter reported as excluded from translation, which are never declared. */
3566
+ readonly excluded: readonly string[];
3567
+ /** Keys the adapter marked as carrying plural forms. Each sibling is declared on its own. */
3568
+ readonly plural: readonly string[];
3569
+ /** Whether the declaration file was written. Always false in `check` mode. */
3570
+ readonly written: boolean;
3571
+ /** Whether the file on disk differed from the freshly generated declaration when the run started. */
3572
+ readonly stale: boolean;
3573
+ /** Whether this was a `check` run. */
3574
+ readonly check: boolean;
3575
+ }
3576
+ /**
3577
+ * Generates a TypeScript declaration for a project's source catalog: a union of every key it holds
3578
+ * and, per key, the arguments its message interpolates. A consumer that types its translation
3579
+ * function against it turns a misspelled key and a missing interpolation argument into compile
3580
+ * errors instead of runtime lookup failures.
3581
+ *
3582
+ * It reads one file (the source locale catalog) and writes one file (the declaration). It
3583
+ * constructs no provider, reads no API key and makes no network request, so it runs on a fresh
3584
+ * checkout before any key exists.
3585
+ *
3586
+ * The keys are exactly what the format adapter produced when reading the catalog, in document
3587
+ * order, so two runs over an unchanged catalog write byte-identical bytes. Arguments come from the
3588
+ * placeholder tokens the adapter extracted, except for the ICU message formats (`next-intl-json`
3589
+ * and `arb`): there each message is analysed with the same ICU parser the adapter uses, so an
3590
+ * argument that only some `select` or `plural` branches use is still declared, as optional. Keys
3591
+ * are emitted as quoted string literals, so a key carrying a dot, a reserved word, a leading digit,
3592
+ * a quote, or nothing at all is declared verbatim rather than dropped or re-split.
3593
+ *
3594
+ * What it will not claim is as important as what it will. A message whose placeholders name their
3595
+ * arguments gets an object shape; one whose placeholders are numbered or anonymous gets a readonly
3596
+ * tuple; one that takes nothing gets a shape that makes passing an argument a type error. A
3597
+ * message whose syntax the adapter reported as invalid, and one that appears to name and number
3598
+ * its arguments at once, are declared with {@link GenerateTypesResult.unresolved} recording why,
3599
+ * rather than being silently declared as taking nothing. Argument types come only from what the
3600
+ * catalog actually records: `number` where the format annotated one, and a `string | number` alias
3601
+ * everywhere else, never a permissive `any`. A name used with several types is declared as the
3602
+ * union of what each use accepts.
3603
+ *
3604
+ * With `check` set, nothing is written: the run reports whether the committed file still matches
3605
+ * what a fresh generation would produce, which is the shape a CI gate wants.
3606
+ *
3607
+ * @param input - The config, the output path, and whether to check rather than write.
3608
+ * @param deps - Optional adapter registry and file-system overrides.
3609
+ * @returns What was declared, and whether the file on disk matched.
3610
+ *
3611
+ * @example
3612
+ * ```ts
3613
+ * const result = await generateTypes({ config });
3614
+ * console.log(`${result.keys} keys declared in ${result.path}`);
3615
+ * ```
3616
+ *
3617
+ * @throws {@link SdkError} `TYPES_OUTPUT_CONFLICT`: the output path is refused (see
3618
+ * {@link GenerateTypesInput.out} for the full set), or a generating run found a file there that
3619
+ * does not begin with the header verbatra writes.
3620
+ * @throws {@link SdkError} `TYPES_UNWRITABLE`: the declaration file could not be written.
3621
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
3622
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
3623
+ * cannot be combined.
3624
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same
3625
+ * path.
3626
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
3627
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
3628
+ */
3629
+ declare function generateTypes(input: GenerateTypesInput, deps?: GenerateTypesDeps): Promise<GenerateTypesResult>;
3630
+
1609
3631
  /** One key's placeholder and ICU verdict in a {@link LocaleKeyIntegrity} report. */
1610
3632
  interface KeyIntegrityEntry {
1611
3633
  /** The key this verdict describes. */
@@ -1620,6 +3642,20 @@ interface KeyIntegrityEntry {
1620
3642
  readonly extra: readonly string[];
1621
3643
  /** True when the translation parses as a valid ICU message under the configured format. */
1622
3644
  readonly icuValid: boolean;
3645
+ /**
3646
+ * True when the translation carries the source's inline HTML or XML tags, well formed. Compared
3647
+ * exactly as the write-time gate compares them, so a value already on disk is judged by the same
3648
+ * rule as one about to be written.
3649
+ */
3650
+ readonly markupMatches: boolean;
3651
+ /**
3652
+ * The tags behind a `markupMatches: false` verdict, each prefixed with `-` for one the source has
3653
+ * and the translation lacks or `+` for one the translation added, or `+more than N inline tags`
3654
+ * for a translation carrying more than twice its source's inline tags and constructs (N is at
3655
+ * least 256). Empty when the markup matches, and empty when no single tag is at fault because
3656
+ * the translation's markup is structurally broken.
3657
+ */
3658
+ readonly markupDetails: readonly string[];
1623
3659
  }
1624
3660
  /** One locale's per-key integrity verdicts. */
1625
3661
  interface LocaleKeyIntegrity {
@@ -1655,9 +3691,13 @@ interface KeyIntegrityDeps {
1655
3691
  * edit may have just invalidated. Only keys present in both the source and the target are judged,
1656
3692
  * since a missing translation has no placeholders to compare.
1657
3693
  *
1658
- * This is the read-only counterpart to the gate that {@link editEntry} and
1659
- * {@link retranslateEntry} enforce at write time, and the data behind a review dashboard's
1660
- * per-key integrity indicator.
3694
+ * This is the read-only counterpart to the placeholder, ICU and inline-markup checks the gate that
3695
+ * {@link editEntry} and {@link retranslateEntry} enforce at write time, and the data behind a
3696
+ * review dashboard's per-key integrity indicator. Reporting markup here is what makes drift that
3697
+ * predates the gate visible at all: a translation written before the check existed, edited outside
3698
+ * verbatra, or produced by a path that never crossed the gate is judged here by the same rule.
3699
+ * Emptiness and degeneracy are deliberately not reported: both describe a candidate that was
3700
+ * refused rather than a state a stored translation can usefully be in.
1661
3701
  *
1662
3702
  * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
1663
3703
  * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
@@ -1808,7 +3848,91 @@ interface LocaleSnapshotDelta {
1808
3848
  * @param current - The later snapshot.
1809
3849
  * @returns The added, changed, and removed key counts.
1810
3850
  */
1811
- declare function diffLocaleSnapshots(previous: LocaleFileSnapshot, current: LocaleFileSnapshot): LocaleSnapshotDelta;
3851
+ declare function diffLocaleSnapshots(previous: LocaleFileSnapshot, current: LocaleFileSnapshot): LocaleSnapshotDelta;
3852
+
3853
+ /** One key's current source and target text within a {@link LocaleValues} map. */
3854
+ interface KeyValuePair {
3855
+ /** The key's text in the source locale, absent when the key exists only in this target (orphaned). */
3856
+ readonly source?: string;
3857
+ /** The key's text in this target locale, absent when the key has not been translated yet. */
3858
+ readonly target?: string;
3859
+ }
3860
+ /** One target locale's current source and target text for every key, as returned by {@link localeValues}. */
3861
+ interface LocaleValues {
3862
+ /** The target locale these values were read for. */
3863
+ readonly locale: string;
3864
+ /**
3865
+ * Source and target text per key, keyed by key name. Covers missing, changed, orphaned, and
3866
+ * in-sync keys alike.
3867
+ */
3868
+ readonly values: Readonly<Record<string, KeyValuePair>>;
3869
+ }
3870
+ /** Input for {@link localeValues}. */
3871
+ interface LocaleValuesInput {
3872
+ /** The resolved project config, normally from {@link loadConfig}. */
3873
+ readonly config: VerbatraConfig;
3874
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
3875
+ readonly cwd?: string;
3876
+ /** Restrict the report to these target locales. Defaults to every configured target locale. */
3877
+ readonly locales?: readonly string[];
3878
+ }
3879
+ /** Injectable dependencies for {@link localeValues}. Every field has a working default. */
3880
+ interface LocaleValuesDeps {
3881
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
3882
+ readonly adapterRegistry?: AdapterRegistry;
3883
+ /** File-system port. Defaults to the real file system. */
3884
+ readonly fs?: SdkFs;
3885
+ }
3886
+ /**
3887
+ * Reads every key's current source and target text, across every requested target locale, in one
3888
+ * pass over the files already on disk. It writes nothing and calls no provider.
3889
+ *
3890
+ * This exists to back client-side search over translation content rather than key names alone: a
3891
+ * caller holding the result in memory can match a query against source or target text without a
3892
+ * further round trip per key. {@link keyValue} and {@link diff} answer narrower questions (one key,
3893
+ * or key names only); this is the bulk read a caller would otherwise have to simulate with many
3894
+ * {@link keyValue} calls.
3895
+ *
3896
+ * A key present in the source but not yet translated in a locale reports `target` as absent; a key
3897
+ * present in a locale but no longer in the source (orphaned) reports `source` as absent instead. A
3898
+ * target locale file that does not exist yet is treated as empty rather than as an error, so every
3899
+ * key in a newly added locale reports `target` as absent.
3900
+ *
3901
+ * Note that a malformed target locale file surfaces the adapter's own error and code rather than a
3902
+ * wrapped {@link SdkError}, because only source reads are wrapped. Its message names the offending
3903
+ * locale and the resolved path. A caller that maps SDK codes should be ready for an unrecognized
3904
+ * error from a target file.
3905
+ *
3906
+ * @param input - The config and the optional locale filter.
3907
+ * @param deps - Optional adapter registry and file-system overrides.
3908
+ * @returns Per-locale source and target text, keyed by key name.
3909
+ *
3910
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
3911
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
3912
+ * cannot be combined, or a configured locale has no valid path spelling under that style.
3913
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
3914
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
3915
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
3916
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
3917
+ *
3918
+ * @example
3919
+ * ```ts
3920
+ * import { loadConfig, localeValues } from "@verbatra/sdk";
3921
+ *
3922
+ * const config = await loadConfig();
3923
+ * const locales = await localeValues({ config });
3924
+ *
3925
+ * const needle = "welcome";
3926
+ * for (const locale of locales) {
3927
+ * for (const [key, pair] of Object.entries(locale.values)) {
3928
+ * if (pair.target?.toLowerCase().includes(needle)) {
3929
+ * console.log(`${locale.locale} ${key}: ${pair.target}`);
3930
+ * }
3931
+ * }
3932
+ * }
3933
+ * ```
3934
+ */
3935
+ declare function localeValues(input: LocaleValuesInput, deps?: LocaleValuesDeps): Promise<readonly LocaleValues[]>;
1812
3936
 
1813
3937
  /** One locale's lock-file baseline and the drift measured against it. */
1814
3938
  interface LockLocaleState {
@@ -1887,313 +4011,229 @@ interface LockStateDeps {
1887
4011
  */
1888
4012
  declare function lockState(input: LockStateInput, deps?: LockStateDeps): Promise<LockStateResult>;
1889
4013
 
1890
- /**
1891
- * Builds a {@link TranslationProvider} from the config's `provider` block. Passed as
1892
- * `deps.createProvider` to {@link translate}, {@link watch}, and {@link retranslateEntry}, it is the
1893
- * seam for injecting a stub in a test or a provider the SDK does not ship.
1894
- *
1895
- * The default implementation dispatches on the provider ID and reads the API key from the
1896
- * environment. A factory that throws is reported as `PROVIDER_CONSTRUCTION_FAILED`.
1897
- */
1898
- type CreateProvider = (config: ProviderConfig) => TranslationProvider;
1899
-
1900
- /** Input for {@link retranslateEntry}. */
1901
- interface RetranslateEntryInput {
4014
+ /** Input for {@link pseudolocalize}. */
4015
+ interface PseudolocalizeInput {
1902
4016
  /** The resolved project config, normally from {@link loadConfig}. */
1903
4017
  readonly config: VerbatraConfig;
1904
4018
  /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
1905
4019
  readonly cwd?: string;
1906
- /** The target locale to retranslate into. Must be a configured target locale. */
1907
- readonly locale: string;
1908
- /** The key to retranslate. Must exist in the source resource. */
1909
- readonly key: string;
4020
+ /**
4021
+ * The pseudolocale's BCP-47 code. Defaults to `en-XA`. It must not be the source locale or any
4022
+ * configured target locale, so a pseudolocale can never stand in for a real translation.
4023
+ */
4024
+ readonly locale?: string;
4025
+ /**
4026
+ * Directory the pseudolocale file is written under, relative to `cwd`. Defaults to
4027
+ * `.verbatra-local/pseudo`, which `verbatra init` already adds to `.gitignore`. The configured
4028
+ * `files.pattern` is expanded inside it, so the file keeps the layout the application expects.
4029
+ *
4030
+ * It must name a directory inside `cwd`: an absolute path, one that climbs out with `..`, and
4031
+ * `cwd` itself are all refused, as is any directory that already holds a configured locale file,
4032
+ * so a generated pseudolocale never lands outside the project or beside the real translations
4033
+ * where nothing ignores it.
4034
+ */
4035
+ readonly out?: string;
1910
4036
  }
1911
- /** Injectable dependencies for {@link retranslateEntry}. Every field has a working default. */
1912
- interface RetranslateEntryDeps {
4037
+ /** Injectable dependencies for {@link pseudolocalize}. Every field has a working default. */
4038
+ interface PseudolocalizeDeps {
1913
4039
  /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
1914
4040
  readonly adapterRegistry?: AdapterRegistry;
1915
- /** Provider factory. Defaults to constructing the provider named in the config. */
1916
- readonly createProvider?: CreateProvider;
1917
4041
  /** File-system port. Defaults to the real file system. */
1918
4042
  readonly fs?: SdkFs;
1919
4043
  }
1920
- /**
1921
- * The outcome of {@link retranslateEntry}. As with {@link EditEntryResult}, a value refused by the
1922
- * integrity gate is reported as data rather than thrown, because it is an expected outcome of
1923
- * asking a provider for a fresh translation.
1924
- */
1925
- type RetranslateEntryResult = {
1926
- /** The provider's value passed the integrity gate and was written. */
1927
- readonly accepted: true;
1928
- /** The newly translated value now stored for the key. */
1929
- readonly value: string;
4044
+ /** The result of {@link pseudolocalize}: what was generated and where it landed. */
4045
+ interface PseudolocalizeResult {
4046
+ /** The pseudolocale that was generated. */
4047
+ readonly locale: string;
4048
+ /** Absolute path of the file that was written, or would have been written on a changed run. */
4049
+ readonly path: string;
4050
+ /** How many source entries were processed. */
4051
+ readonly entries: number;
4052
+ /** How many entries carry a pseudolocalized value. */
4053
+ readonly transformed: number;
1930
4054
  /**
1931
- * Quality signals the provider layer raised for this value, such as a length-ratio outlier or
1932
- * a value identical to the source. Empty when nothing was flagged. The value is written
1933
- * either way; these are advisory.
4055
+ * Keys whose source value was copied verbatim because a pseudolocalized value would not have
4056
+ * passed the same integrity gate a translation must pass. Never a failure: the entry is still
4057
+ * present and still renders.
1934
4058
  */
1935
- readonly reviewReasons: readonly ReviewReasonCode[];
1936
- } | {
1937
- /** The value was refused; nothing was written and the previous translation is intact. */
1938
- readonly accepted: false;
1939
- /** Which integrity rule the provider's value broke. */
1940
- readonly reason: IntegrityGateReason;
1941
- /** The rejected value, echoed back so a UI can show what was refused. */
1942
- readonly value: string;
1943
- };
4059
+ readonly copied: readonly string[];
4060
+ /** False when the file on disk already matched, so nothing was rewritten. */
4061
+ readonly written: boolean;
4062
+ }
1944
4063
  /**
1945
- * Re-runs the configured provider for a single key and saves the result. This is the paid
1946
- * counterpart to {@link editEntry}: it calls the provider and therefore spends tokens, which is why
1947
- * a UI should gate it behind an explicit user action.
1948
- *
1949
- * The returned value goes through the same integrity gate as a full run, so a translation that
1950
- * loses a placeholder or breaks ICU syntax is refused and nothing is written. Provider quality
1951
- * signals are surfaced on an accepted result as `reviewReasons` rather than blocking the write.
1952
- *
1953
- * The locale's write lock is taken before the provider is called and held across it, covering the
1954
- * whole read-translate-write cycle, so a slow provider keeps the lock for the length of its call
1955
- * and a concurrent {@link translate} run on that locale waits. The lock is held for a refused
1956
- * translation too, since the gate runs inside it. An accepted value then updates the lock-file
1957
- * baseline and feeds the translation memory, so a later {@link translate} run sees the key as up
1958
- * to date.
1959
- *
1960
- * Note that the target locale file surfaces the adapter's own error and code rather than a wrapped
1961
- * {@link SdkError}, on the write as well as on the read, because only the source read is wrapped.
1962
- * A malformed target file fails the read with a message naming the offending locale and the
1963
- * resolved path. The write raises the adapter's error too, when the entries cannot be represented
1964
- * in the configured format or the existing destination file cannot be read back to be updated in
1965
- * place, and that error is re-thrown unchanged so its own code survives for a caller that maps
1966
- * adapter codes to its own copy. A caller that maps SDK codes should be ready for an unrecognized
1967
- * error from a target file on either path.
4064
+ * Generates a pseudolocale from the source strings alone: every value is accented, expanded by
4065
+ * roughly a third of its translatable length, and wrapped in `[` and `]` boundary markers, so a
4066
+ * truncated or concatenated string is obvious on screen and an untranslated hardcoded string stands
4067
+ * out for having escaped the transform.
4068
+ *
4069
+ * It constructs no provider, reads no API key and makes no network request, so it runs on a fresh
4070
+ * checkout before any key exists or any budget is approved.
4071
+ *
4072
+ * Placeholders survive untouched in every syntax the shipped adapters recognise, and every
4073
+ * generated value is held to the same integrity gate a provider's translation must pass. A value
4074
+ * that would not pass it is copied from the source verbatim and reported in
4075
+ * {@link PseudolocalizeResult.copied} rather than written in a broken form.
4076
+ *
4077
+ * The output is deliberately kept out of the project's real locale files: it is written under
4078
+ * `.verbatra-local/pseudo` by default, which `verbatra init` already adds to `.gitignore`, and the
4079
+ * pseudolocale is refused when it names a configured locale, when the output directory already
4080
+ * holds a configured locale file, and when it is directed at the project root or outside the
4081
+ * working directory. Because it lives outside `files.pattern`, {@link translate} never spends on it and
4082
+ * {@link check} and {@link diff} never report it as drifted.
4083
+ *
4084
+ * The transform is deterministic, so a second run over unchanged source rewrites nothing and
4085
+ * reports {@link PseudolocalizeResult.written} as false. For `xliff` and `apple-xcstrings`, whose
4086
+ * writers only patch units already present in the destination, the output is re-copied from the
4087
+ * source on every write, so a key added to the source after the first run still reaches the
4088
+ * pseudolocale; a copied XLIFF has its target-language attribute rewritten to the pseudolocale, so
4089
+ * the file never misdescribes what it holds.
4090
+ *
4091
+ * @param input - The config, the pseudolocale code, and the output directory.
4092
+ * @param deps - Optional adapter registry and file-system overrides.
4093
+ * @returns Where the pseudolocale landed, how many entries it carries, and whether it changed.
1968
4094
  *
1969
- * @param input - The config, locale, and key to retranslate.
1970
- * @param deps - Optional adapter registry, provider factory, and file-system overrides.
1971
- * @returns Whether the new value was accepted, with review reasons or the rejection reason.
4095
+ * @example
4096
+ * ```ts
4097
+ * const result = await pseudolocalize({ config });
4098
+ * console.log(`${result.transformed} of ${result.entries} entries in ${result.path}`);
4099
+ * ```
1972
4100
  *
4101
+ * @throws {@link SdkError} `PSEUDO_OUTPUT_CONFLICT`: the pseudolocale names a configured locale, the
4102
+ * output directory already holds a configured locale file, or it is not a relative path naming a
4103
+ * directory inside `cwd`.
1973
4104
  * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
1974
- * @throws {@link SdkError} `UNKNOWN_LOCALE`: the requested locale is not a configured target locale.
1975
4105
  * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
1976
- * cannot be combined, or the locale has no valid path spelling under that style.
4106
+ * cannot be combined, or the pseudolocale has no valid path spelling under that style.
1977
4107
  * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
1978
4108
  * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
1979
- * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
1980
- * @throws {@link SdkError} `UNKNOWN_KEY`: the key is not present in the source resource.
1981
- * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider could not be constructed,
1982
- * most often because its API key environment variable is unset.
1983
- * @throws {@link SdkError} `LOCK_CONTENDED`: the locale's write lock could not be acquired before
1984
- * the timeout elapsed.
1985
- * @throws {@link SdkError} `TARGET_UNWRITABLE`: the target locale file could not be written because
1986
- * of a file-system failure. The message names the target file and the file-system code, never the
1987
- * internal temporary file.
1988
- * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
1989
- * unsupported version.
1990
- * @throws `AdapterError`: the adapter itself refused the target locale file, on the read because it
1991
- * is malformed or on the write because the entries cannot be represented in the configured format.
1992
- * Its own code is preserved rather than remapped onto an {@link SdkErrorCode}.
1993
- * @throws `ProviderError` `INVALID_RESPONSE`: the provider returned no value for the key. Provider
1994
- * transport and rate-limit failures propagate as `ProviderError` too, since a single-key call has
1995
- * no per-locale summary to record them on.
4109
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed, or, for a
4110
+ * format whose writer only patches an existing document (`xliff` and `apple-xcstrings`), the
4111
+ * source file could not be copied to seed the output.
4112
+ * @throws {@link SdkError} `TARGET_UNWRITABLE`: the pseudolocale file could not be written.
1996
4113
  */
1997
- declare function retranslateEntry(input: RetranslateEntryInput, deps?: RetranslateEntryDeps): Promise<RetranslateEntryResult>;
4114
+ declare function pseudolocalize(input: PseudolocalizeInput, deps?: PseudolocalizeDeps): Promise<PseudolocalizeResult>;
1998
4115
 
1999
4116
  /**
2000
- * Conditions the SDK itself reports on a locale, as opposed to the {@link ProviderNotice} codes a
2001
- * provider raises. Each marks a run that completed but did something a caller may want to know
2002
- * about.
2003
- *
2004
- * - `PLURAL_CATEGORIES_INCOMPLETE`: plural generation could not produce every category the target
2005
- * language requires, so the entry is written with the categories that were produced.
2006
- * - `SUB_BATCH_FAILED`: one sub-batch of a locale failed while others succeeded. The locale is
2007
- * reported as `partial` rather than failed.
2008
- * - `BLANK_ROW_BASELINE_RETAINED`: an imported handoff row was blank, so the existing translation
2009
- * and its lock-file baseline were kept rather than being erased.
2010
- * - `BUDGET_TOKENS_EXCEEDED`: the configured token budget was passed. Under `warn` the run
2011
- * continues; under `stop` the remaining keys are withheld.
2012
- * - `CACHE_VERSION_UNRECOGNIZED`: the translation memory is at a version this release does not
2013
- * understand, so it was ignored rather than trusted.
2014
- */
2015
- type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED" | "BLANK_ROW_BASELINE_RETAINED" | "BUDGET_TOKENS_EXCEEDED" | "CACHE_VERSION_UNRECOGNIZED";
2016
- /**
2017
- * Token usage as reported by the provider. Absent when the provider does not report usage, which is
2018
- * the case for machine-translation APIs such as DeepL that do not bill in tokens.
2019
- */
2020
- interface UsageSummary {
2021
- /** Tokens consumed by the prompts sent to the provider. */
2022
- readonly inputTokens: number;
2023
- /** Tokens consumed by the provider's responses. */
2024
- readonly outputTokens: number;
2025
- }
2026
- /**
2027
- * What a run does when it passes its token budget: `warn` finishes the work and reports the
2028
- * overrun, `stop` withholds the remaining keys.
2029
- */
2030
- type BudgetBehavior = "warn" | "stop";
2031
- /** The token budget in force for a run, and how much of it was actually consumed. */
2032
- interface RunBudget {
2033
- /** The configured ceiling, in tokens. */
2034
- readonly maxTokens: number;
2035
- /** Whether passing the ceiling warns or stops the run. */
2036
- readonly behavior: BudgetBehavior;
2037
- /**
2038
- * Whether the configured provider reports usage at all. When false the budget cannot be enforced,
2039
- * because there is nothing to count.
2040
- */
2041
- readonly supported: boolean;
2042
- /** Tokens consumed across the whole run so far. */
2043
- readonly tokensUsed: number;
2044
- /** True once `tokensUsed` passed `maxTokens`. */
2045
- readonly exceeded: boolean;
2046
- }
2047
- /** A condition the SDK reported on a locale. See {@link SdkNoticeCode}. */
2048
- interface SdkNotice {
2049
- /** The stable notice code. Branch on this, not on the message. */
2050
- readonly code: SdkNoticeCode;
2051
- /** A human-readable description of the condition. */
2052
- readonly message: string;
2053
- }
2054
- /**
2055
- * Any notice attached to a locale: either one the provider raised, such as a downgraded formality
2056
- * or an ignored glossary, or one the SDK raised. Discriminate on `code`.
2057
- */
2058
- type LocaleNotice = ProviderNotice | SdkNotice;
2059
- /**
2060
- * A translated key the provider layer flagged as worth a human look. The translation was still
2061
- * written; these are advisory quality signals, not rejections.
2062
- */
2063
- interface NeedsReviewEntry {
2064
- /** The key that was flagged. */
2065
- readonly key: string;
2066
- /** Why it was flagged. A key can carry more than one reason. */
2067
- readonly reasons: readonly ReviewReasonCode[];
2068
- }
2069
- /** A row of an imported handoff that could not be read. Reported rather than aborting the import. */
2070
- interface MalformedRowReport {
2071
- /** The row's 1-based index within its sheet or file. */
2072
- readonly row: number;
2073
- /**
2074
- * The 1-based physical line in the source file, for delimited formats where a quoted value can
2075
- * span several lines. Absent for `.xlsx`, which has rows but no lines.
2076
- */
2077
- readonly line?: number;
2078
- /** The column whose value was missing or unusable. */
2079
- readonly column: string;
2080
- }
2081
- /**
2082
- * A key that appeared more than once in an imported handoff. The first occurrence wins and the rest
2083
- * are reported here, so a translator who duplicated a row learns which value was actually used.
2084
- */
2085
- interface DuplicateKeyReport {
2086
- /** The key that appeared more than once. */
2087
- readonly key: string;
2088
- /** The 1-based row index of the ignored occurrence. */
2089
- readonly row: number;
2090
- /** The 1-based physical line of the ignored occurrence, for delimited formats. Absent for `.xlsx`. */
2091
- readonly line?: number;
2092
- }
2093
- /**
2094
- * Everything that happened for one locale during a run. The key lists are disjoint accounts of what
2095
- * became of each key, so a caller can reconstruct the whole run without re-reading any file.
4117
+ * Builds a {@link TranslationProvider} from the config's `provider` block. Passed as
4118
+ * `deps.createProvider` to {@link translate}, {@link watch}, and {@link retranslateEntry}, it is the
4119
+ * seam for injecting a stub in a test or a provider the SDK does not ship.
2096
4120
  *
2097
- * A locale that failed outright still appears here with `status: "failed"` and an `error`, rather
2098
- * than the whole run throwing. That is the central contract of {@link translate}: one unreachable
2099
- * provider or one unwritable file does not discard the locales that succeeded.
2100
- */
2101
- interface LocaleSummary {
2102
- /** The target locale this summary describes. */
2103
- readonly locale: string;
2104
- /**
2105
- * `succeeded` when everything asked for was done, `partial` when some keys were translated and
2106
- * others were not, and `failed` when the locale produced no usable result.
2107
- */
2108
- readonly status: "succeeded" | "partial" | "failed";
2109
- /** Keys newly translated by the provider in this run. */
2110
- readonly translated: readonly string[];
2111
- /** Keys already up to date against the lock-file baseline, so no provider call was made. */
2112
- readonly unchanged: readonly string[];
2113
- /** Keys present in this locale but no longer in the source. Reported, and removed only when pruning. */
2114
- readonly orphaned: readonly string[];
2115
- /** Orphaned keys that were actually removed, which happens only when the run was asked to prune. */
2116
- readonly pruned: readonly string[];
2117
- /**
2118
- * Keys whose source text is not valid ICU. They are skipped rather than sent to the provider,
2119
- * because a broken source message cannot yield a sound translation.
2120
- */
2121
- readonly invalidIcuSource: readonly string[];
2122
- /** Keys served from the translation memory instead of the provider, and so not paid for. */
2123
- readonly cacheHits: readonly string[];
2124
- /**
2125
- * Keys whose translation was refused by the integrity gate, for instance because it dropped a
2126
- * placeholder. The previous translation, if any, is left untouched.
2127
- */
2128
- readonly integrityMismatches: readonly string[];
2129
- /** Keys the provider failed to translate, for instance because their sub-batch errored. */
2130
- readonly providerFailures: readonly string[];
2131
- /** Keys whose plural categories were generated for the target language rather than translated one by one. */
2132
- readonly generated: readonly string[];
2133
- /** Keys not translated because the token budget was exhausted under `stop` behavior. */
2134
- readonly budgetWithheld: readonly string[];
2135
- /** Token usage for this locale. Absent when the provider does not report usage. */
2136
- readonly usage?: UsageSummary;
2137
- /** Provider and SDK notices raised while running this locale. Always present, possibly empty. */
2138
- readonly notices: readonly LocaleNotice[];
2139
- /** Translated keys flagged as worth a human look. The translations were still written. */
2140
- readonly needsReview: readonly NeedsReviewEntry[];
2141
- /** Keys left with no translation after the run, whatever the cause. */
2142
- readonly unfilled: readonly string[];
2143
- /** Unreadable rows from an imported handoff. Always empty for a {@link translate} run. */
2144
- readonly malformedRows: readonly MalformedRowReport[];
2145
- /** Repeated keys from an imported handoff. Always empty for a {@link translate} run. */
2146
- readonly duplicateKeys: readonly DuplicateKeyReport[];
2147
- /**
2148
- * Why this locale failed, when the failure came from a thrown error. The `code` is the failure's
2149
- * own code where it has one, and `LOCALE_FAILED` otherwise. Never carries a secret.
2150
- *
2151
- * Absent unless `status` is `failed`, but a `failed` locale does not always carry it: a locale
2152
- * whose every key was withheld by the integrity gate, a provider failure, or the token budget is
2153
- * `failed` with nothing thrown, so this stays undefined and the withheld keys are the account of
2154
- * what went wrong. Treat it as an optional detail on a failure, never as the failure test.
2155
- */
2156
- readonly error?: {
2157
- /** The failure's own code where it had one, and `LOCALE_FAILED` otherwise. */
2158
- readonly code: string;
2159
- /** A human-readable description of the failure. Never contains a secret. */
2160
- readonly message: string;
2161
- };
4121
+ * The default implementation dispatches on the provider ID and reads the API key from the
4122
+ * environment. A factory that throws is reported as `PROVIDER_CONSTRUCTION_FAILED`.
4123
+ */
4124
+ type CreateProvider = (config: ProviderConfig) => TranslationProvider;
4125
+
4126
+ /** Input for {@link retranslateEntry}. */
4127
+ interface RetranslateEntryInput {
4128
+ /** The resolved project config, normally from {@link loadConfig}. */
4129
+ readonly config: VerbatraConfig;
4130
+ /** Directory the `files.pattern` is resolved against. Defaults to the process working directory. */
4131
+ readonly cwd?: string;
4132
+ /** The target locale to retranslate into. Must be a configured target locale. */
4133
+ readonly locale: string;
4134
+ /** The key to retranslate. Must exist in the source resource. */
4135
+ readonly key: string;
4136
+ }
4137
+ /** Injectable dependencies for {@link retranslateEntry}. Every field has a working default. */
4138
+ interface RetranslateEntryDeps {
4139
+ /** Format-adapter registry to resolve the configured format. Defaults to the built-in registry. */
4140
+ readonly adapterRegistry?: AdapterRegistry;
4141
+ /** Provider factory. Defaults to constructing the provider named in the config. */
4142
+ readonly createProvider?: CreateProvider;
4143
+ /** File-system port. Defaults to the real file system. */
4144
+ readonly fs?: SdkFs;
2162
4145
  }
2163
4146
  /**
2164
- * The result of a whole run, returned by {@link translate}, {@link watch}, and
2165
- * {@link importWorkbook}.
2166
- *
2167
- * Per-locale outcomes are data, not exceptions: inspect `failed` and `partial` to decide an exit
2168
- * code rather than relying on the call to throw. Only whole-run failures, such as an invalid config
2169
- * or an unreadable source file, throw an {@link SdkError}.
4147
+ * The outcome of {@link retranslateEntry}. As with {@link EditEntryResult}, a value refused by the
4148
+ * integrity gate is reported as data rather than thrown, because it is an expected outcome of
4149
+ * asking a provider for a fresh translation.
2170
4150
  */
2171
- interface RunSummary {
2172
- /** True when the run computed everything but wrote nothing and called no provider. */
2173
- readonly dryRun: boolean;
4151
+ type RetranslateEntryResult = {
4152
+ /** The provider's value passed the integrity gate and was written. */
4153
+ readonly accepted: true;
4154
+ /** The newly translated value now stored for the key. */
4155
+ readonly value: string;
2174
4156
  /**
2175
- * The full per-locale account. A {@link translate} or {@link watch} run reports them in
2176
- * configured target order, whatever order a `locales` subset was given in and whatever order
2177
- * concurrent locales finished in. An {@link importWorkbook} run does not: it reports them in the
2178
- * order the handoff yielded them, then appends the configured locales the handoff had nothing
2179
- * for. Match an entry on its `locale` rather than on its position.
4157
+ * Quality signals the provider layer raised for this value, such as a length-ratio outlier or
4158
+ * a value identical to the source. Empty when nothing was flagged. The value is written
4159
+ * either way; these are advisory.
2180
4160
  */
2181
- readonly locales: readonly LocaleSummary[];
2182
- /** Names of the locales whose status is `succeeded`. */
2183
- readonly succeeded: readonly string[];
4161
+ readonly reviewReasons: readonly ReviewReasonCode[];
4162
+ } | {
4163
+ /** The value was refused; nothing was written and the previous translation is intact. */
4164
+ readonly accepted: false;
4165
+ /** Which integrity rule the provider's value broke. */
4166
+ readonly reason: IntegrityGateReason;
2184
4167
  /**
2185
- * Names of the locales whose status is `partial`: written to disk, but with keys still missing.
2186
- * Check this alongside `failed` before treating a run as clean. The CLI exits `1` on a partial
2187
- * locale for that reason.
4168
+ * The specific tags behind a `markup` refusal, each prefixed with `-` for one the source had
4169
+ * and the candidate dropped or `+` for one the candidate invented. A candidate carrying more
4170
+ * than twice its source's inline tags and constructs is named as `+more than N inline tags`
4171
+ * instead, where N is at least 256. Absent when no single tag is at fault, such as markup
4172
+ * that came back mis-nested, and absent for every other reason.
2188
4173
  */
2189
- readonly partial: readonly string[];
2190
- /** Names of the locales whose status is `failed`. Check this before treating a run as clean. */
2191
- readonly failed: readonly string[];
2192
- /** Token usage summed across every locale. Absent when the provider does not report usage. */
2193
- readonly usage?: UsageSummary;
2194
- /** The token budget in force, present only when the config set one. */
2195
- readonly budget?: RunBudget;
2196
- }
4174
+ readonly details?: readonly string[];
4175
+ /** The rejected value, echoed back so a UI can show what was refused. */
4176
+ readonly value: string;
4177
+ };
4178
+ /**
4179
+ * Re-runs the configured provider for a single key and saves the result. This is the paid
4180
+ * counterpart to {@link editEntry}: it calls the provider and therefore spends tokens, which is why
4181
+ * a UI should gate it behind an explicit user action.
4182
+ *
4183
+ * The returned value goes through the same integrity gate as a full run, so a translation that
4184
+ * loses a placeholder or breaks ICU syntax is refused and nothing is written. Provider quality
4185
+ * signals are surfaced on an accepted result as `reviewReasons` rather than blocking the write.
4186
+ *
4187
+ * The locale's write lock is taken before the provider is called and held across it, covering the
4188
+ * whole read-translate-write cycle, so a slow provider keeps the lock for the length of its call
4189
+ * and a concurrent {@link translate} run on that locale waits. The lock is held for a refused
4190
+ * translation too, since the gate runs inside it. An accepted value then updates the lock-file
4191
+ * baseline and feeds the translation memory, so a later {@link translate} run sees the key as up
4192
+ * to date.
4193
+ *
4194
+ * Note that the target locale file surfaces the adapter's own error and code rather than a wrapped
4195
+ * {@link SdkError}, on the write as well as on the read, because only the source read is wrapped.
4196
+ * A malformed target file fails the read with a message naming the offending locale and the
4197
+ * resolved path. The write raises the adapter's error too, when the entries cannot be represented
4198
+ * in the configured format or the existing destination file cannot be read back to be updated in
4199
+ * place, and that error is re-thrown unchanged so its own code survives for a caller that maps
4200
+ * adapter codes to its own copy. A caller that maps SDK codes should be ready for an unrecognized
4201
+ * error from a target file on either path.
4202
+ *
4203
+ * It spends outside the token budget. `maxTokens` and `budgetBehavior` bound a {@link translate}
4204
+ * run, and this is its own single-key path with its own provider, so a configured ceiling neither
4205
+ * withholds this call nor counts it. A caller that exposes retranslation to users, as the Studio
4206
+ * dashboard and the agent tools do, has to bound that spend itself.
4207
+ *
4208
+ * @param input - The config, locale, and key to retranslate.
4209
+ * @param deps - Optional adapter registry, provider factory, and file-system overrides.
4210
+ * @returns Whether the new value was accepted, with review reasons or the rejection reason.
4211
+ *
4212
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
4213
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: the requested locale is not a configured target locale.
4214
+ * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the `files.pattern` and `files.localeStyle`
4215
+ * cannot be combined, or the locale has no valid path spelling under that style.
4216
+ * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same path.
4217
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: the source locale file does not exist.
4218
+ * @throws {@link SdkError} `SOURCE_INVALID`: the source locale file could not be parsed.
4219
+ * @throws {@link SdkError} `UNKNOWN_KEY`: the key is not present in the source resource.
4220
+ * @throws {@link SdkError} `PROVIDER_CONSTRUCTION_FAILED`: the provider could not be constructed,
4221
+ * most often because its API key environment variable is unset.
4222
+ * @throws {@link SdkError} `LOCK_CONTENDED`: the locale's write lock could not be acquired before
4223
+ * the timeout elapsed.
4224
+ * @throws {@link SdkError} `TARGET_UNWRITABLE`: the target locale file could not be written because
4225
+ * of a file-system failure. The message names the target file and the file-system code, never the
4226
+ * internal temporary file.
4227
+ * @throws {@link SdkError} `LOCK_FILE_INVALID`: the lock-file is corrupt, oversized, or at an
4228
+ * unsupported version.
4229
+ * @throws `AdapterError`: the adapter itself refused the target locale file, on the read because it
4230
+ * is malformed or on the write because the entries cannot be represented in the configured format.
4231
+ * Its own code is preserved rather than remapped onto an {@link SdkErrorCode}.
4232
+ * @throws `ProviderError` `INVALID_RESPONSE`: the provider returned no value for the key. Provider
4233
+ * transport and rate-limit failures propagate as `ProviderError` too, since a single-key call has
4234
+ * no per-locale summary to record them on.
4235
+ */
4236
+ declare function retranslateEntry(input: RetranslateEntryInput, deps?: RetranslateEntryDeps): Promise<RetranslateEntryResult>;
2197
4237
 
2198
4238
  /**
2199
4239
  * One locale's entry in a persisted {@link RunStatusFile}. It is a deliberately narrow projection
@@ -2207,6 +4247,14 @@ interface RunStatusLocale {
2207
4247
  readonly status: "succeeded" | "partial" | "failed";
2208
4248
  /** Keys the run flagged as worth a human look. */
2209
4249
  readonly needsReview: readonly NeedsReviewEntry[];
4250
+ /**
4251
+ * Translations reused for a source string that had changed, with the score and the earlier
4252
+ * source behind each one. Every key listed here also carries `FUZZY_CACHE_REUSE` in
4253
+ * {@link needsReview}; this is the evidence a reviewer needs to judge the reuse without
4254
+ * re-running. Absent for a locale that reused nothing, and for a file written before the field
4255
+ * existed.
4256
+ */
4257
+ readonly fuzzyHits?: readonly FuzzyCacheHit[];
2210
4258
  /** Token usage for this locale. Absent when the provider does not report usage. */
2211
4259
  readonly usage?: UsageSummary;
2212
4260
  }
@@ -2225,7 +4273,11 @@ interface RunStatusFile {
2225
4273
  readonly generatedAt: string;
2226
4274
  /** Token usage summed across every locale. Absent when the provider does not report usage. */
2227
4275
  readonly usage?: UsageSummary;
2228
- /** The token budget in force during the run, present only when one was configured. */
4276
+ /**
4277
+ * The token budget in force during the run, present only when one was configured. A snapshot
4278
+ * written before the budget was enforced, when `supported: false` meant nothing was counted at
4279
+ * all, has such a budget left out rather than read as a count of zero.
4280
+ */
2229
4281
  readonly budget?: RunBudget;
2230
4282
  /** Per-locale outcomes from the recorded run. */
2231
4283
  readonly locales: readonly RunStatusLocale[];
@@ -2272,6 +4324,314 @@ type RunStatusResult = {
2272
4324
  */
2273
4325
  declare function runStatus(input?: RunStatusInput, deps?: RunStatusDeps): Promise<RunStatusResult>;
2274
4326
 
4327
+ /** Default output path for a TMX export, used when {@link ExportTmxInput.out} is omitted. */
4328
+ declare const DEFAULT_TMX_PATH = "verbatra-memory.tmx";
4329
+ /** How many translation units one locale contributed to the exported file. */
4330
+ interface ExportTmxLocaleCount {
4331
+ /** The configured target locale. */
4332
+ readonly locale: string;
4333
+ /** How many of its memory entries were written. */
4334
+ readonly units: number;
4335
+ }
4336
+ /** What {@link exportTmx} wrote. */
4337
+ interface ExportTmxResult {
4338
+ /** The resolved path the file was written to. */
4339
+ readonly path: string;
4340
+ /** How many `tu` elements the file holds, one per distinct source string. */
4341
+ readonly units: number;
4342
+ /** Per-locale contribution to that total. */
4343
+ readonly locales: readonly ExportTmxLocaleCount[];
4344
+ /**
4345
+ * Translations left out because the memory holds no source text for their content hash, counted
4346
+ * once per locale and hash rather than once per distinct source string. A cache carried forward
4347
+ * from an older schema stores translations without their source, and TMX cannot represent a unit
4348
+ * with no source segment. Those entries fill in as later runs touch the same strings.
4349
+ */
4350
+ readonly withoutSource: number;
4351
+ /**
4352
+ * Characters removed from segment text because XML 1.0 cannot represent them at all, such as a
4353
+ * control character, a lone surrogate or a noncharacter, counted across every written segment.
4354
+ * Writing them would produce a file no conformant parser accepts, so they are left out and counted
4355
+ * rather than dropped silently.
4356
+ */
4357
+ readonly illegalCharactersRemoved: number;
4358
+ }
4359
+ /** Input for {@link exportTmx}. */
4360
+ interface ExportTmxInput {
4361
+ /** The resolved project config, normally from {@link loadConfig}. */
4362
+ readonly config: VerbatraConfig;
4363
+ /** Where to write the file. Defaults to {@link DEFAULT_TMX_PATH}, resolved against `cwd`. */
4364
+ readonly out?: string;
4365
+ /** Directory the output path and the memory are resolved against. Defaults to the process working directory. */
4366
+ readonly cwd?: string;
4367
+ /** Subset of configured target locales to export. Defaults to all of them. */
4368
+ readonly locales?: readonly string[];
4369
+ /** Value for the TMX header's `creationtoolversion`. Defaults to `unknown`. */
4370
+ readonly toolVersion?: string;
4371
+ }
4372
+ /** Injectable dependencies for {@link exportTmx}. Every field has a working default. */
4373
+ interface ExportTmxDeps {
4374
+ /** File-system port. Defaults to the real file system. */
4375
+ readonly fs?: SdkFs;
4376
+ }
4377
+ /**
4378
+ * Writes the project's translation memory out as a TMX 1.4b file, the format every mainstream
4379
+ * translation platform can read. It calls no provider and needs no API key: every value comes from
4380
+ * the memory already on disk.
4381
+ *
4382
+ * Only entries stored under the project's current configuration fingerprint are written, which is
4383
+ * the same set a run would reuse, so the file describes the memory as it is actually being used
4384
+ * rather than every translation the project has ever produced. One `tu` element is written per
4385
+ * distinct source string, carrying the source segment and one target segment per exported locale.
4386
+ * An empty memory produces a valid, empty TMX file rather than an error.
4387
+ *
4388
+ * @param input - The config, the output path, the locale subset, and the tool version to stamp.
4389
+ * @param deps - Optional file-system override.
4390
+ * @returns The path written, the unit count, and what was left out.
4391
+ *
4392
+ * It reads the memory and writes a file; it never changes the memory, which is why the CLI refuses
4393
+ * `--dry-run` and `--overwrite` on this direction rather than accepting and ignoring them.
4394
+ *
4395
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
4396
+ * @throws {@link SdkError} `CONFIG_INVALID`: the source locale and one of the target locales are the
4397
+ * same language tag once case and separators are normalized. Writing that file would produce two
4398
+ * language attributes {@link importTmx} could not tell apart, so the file this project exported
4399
+ * would be one it refuses to read back.
4400
+ *
4401
+ * @example
4402
+ * ```ts
4403
+ * const result = await exportTmx({ config, out: "memory.tmx" });
4404
+ * console.log(`${result.units} units written to ${result.path}`);
4405
+ * ```
4406
+ */
4407
+ declare function exportTmx(input: ExportTmxInput, deps?: ExportTmxDeps): Promise<ExportTmxResult>;
4408
+
4409
+ /**
4410
+ * The two delimiter-separated interchange formats. They are one implementation with one parameter, the
4411
+ * field delimiter: `tsv` is not a second format, it is `csv` with a tab. The member is also the file
4412
+ * extension an export writes and an import reads back.
4413
+ */
4414
+ type DelimitedFormat = "csv" | "tsv";
4415
+ /** Where in an interchange file a refusal happened. */
4416
+ interface ExchangeErrorLocation {
4417
+ /** The 1-based line. */
4418
+ readonly line: number;
4419
+ /** The 1-based column on that line. */
4420
+ readonly column: number;
4421
+ /** The 1-based ordinal of the translation unit the problem sits in, when it sits in one. */
4422
+ readonly unit?: number;
4423
+ }
4424
+
4425
+ /**
4426
+ * Why one translation unit in an imported TMX file was refused. The first five are the shared
4427
+ * integrity gate's own reasons, so an imported unit is held to exactly the standard a provider's
4428
+ * output and a filled translator handoff already face. `sourceBlank` is the one reason particular
4429
+ * to interchange: a unit whose source segment is blank identifies no string, so it could never be
4430
+ * matched and is never stored.
4431
+ */
4432
+ type TmxRejectionReason = IntegrityGateReason | "sourceBlank";
4433
+ /**
4434
+ * Where in a TMX file a `SOURCE_INVALID` refusal from {@link importTmx} happened: the 1-based `line`
4435
+ * and `column`, and the 1-based `unit` ordinal when the problem sits inside a translation unit. Read
4436
+ * it from a caught error with {@link tmxErrorLocation}; the same place is named in the message.
4437
+ */
4438
+ type TmxErrorLocation = ExchangeErrorLocation;
4439
+ /**
4440
+ * Reads where in a TMX file a refusal from {@link importTmx} happened, without casting the error or
4441
+ * its `cause`.
4442
+ *
4443
+ * @param error - Whatever was caught from `importTmx`.
4444
+ * @returns The {@link TmxErrorLocation} when `error` is an {@link SdkError} wrapping a TMX parse
4445
+ * failure that has a place in the file; `undefined` for any other error, including a refusal with
4446
+ * no place in the file such as a missing or oversized file.
4447
+ *
4448
+ * @example
4449
+ * ```ts
4450
+ * try {
4451
+ * await importTmx({ config, file: "legacy.tmx" });
4452
+ * } catch (error) {
4453
+ * const at = tmxErrorLocation(error);
4454
+ * if (at !== undefined) console.error(`line ${at.line}, column ${at.column}`);
4455
+ * }
4456
+ * ```
4457
+ */
4458
+ declare function tmxErrorLocation(error: unknown): TmxErrorLocation | undefined;
4459
+ /** How many units were refused, by reason. See {@link TmxRejectionReason}. */
4460
+ type TmxRejectionCounts = Readonly<Record<TmxRejectionReason, number>>;
4461
+ /** What an import did to the memory for one target locale. */
4462
+ interface ImportTmxLocaleResult {
4463
+ /** The configured target locale, spelled as the config spells it. */
4464
+ readonly locale: string;
4465
+ /** Units stored for the first time. */
4466
+ readonly added: number;
4467
+ /** Units the memory already held with the same translation, so nothing changed. */
4468
+ readonly unchanged: number;
4469
+ /** Units that replaced a different existing translation, which needs `overwrite`. */
4470
+ readonly overwritten: number;
4471
+ /** Units refused because the memory already held a different translation and `overwrite` was off. */
4472
+ readonly kept: number;
4473
+ /** Later units in the same file repeating a source the file had already translated. */
4474
+ readonly duplicates: number;
4475
+ /**
4476
+ * Units carrying two segments of equal standing for this locale with different values, such as
4477
+ * `de-CH` and `de-AT` in a project configured for `de`. Which one is right cannot be known, so
4478
+ * neither is stored. An exact tag always outranks one that only reaches the locale by subtag
4479
+ * prefix, and two segments carrying the same value are not a conflict.
4480
+ */
4481
+ readonly conflicting: number;
4482
+ /** Units refused before they could be stored, by reason. */
4483
+ readonly rejected: TmxRejectionCounts;
4484
+ }
4485
+ /** A language tag in the file that no configured locale could be resolved to. */
4486
+ interface TmxLanguageReport {
4487
+ /** The tag exactly as the file spelled it. */
4488
+ readonly language: string;
4489
+ /** How many units carried a segment in it. */
4490
+ readonly units: number;
4491
+ }
4492
+ /** What {@link importTmx} read and what it wrote. */
4493
+ interface ImportTmxResult {
4494
+ /** Whether the run was a dry run, in which case the memory file was not written. */
4495
+ readonly dryRun: boolean;
4496
+ /** The resolved path the file was read from. */
4497
+ readonly file: string;
4498
+ /** The `srclang` the file's header declared, or undefined if it declared none. */
4499
+ readonly sourceLanguage: string | undefined;
4500
+ /** How many translation units the file held. */
4501
+ readonly units: number;
4502
+ /** Per-target-locale account of what was stored. */
4503
+ readonly locales: readonly ImportTmxLocaleResult[];
4504
+ /** Units the reader could not use at all, such as a unit with no segment. */
4505
+ readonly skippedUnits: number;
4506
+ /** `tu` elements outside the file's first `body`, which the reader never walked. */
4507
+ readonly unreachableUnits: number;
4508
+ /**
4509
+ * The header's `srclang` when it does not resolve to the configured source locale, so a file
4510
+ * exported from a project with a different source language is visible rather than silently
4511
+ * yielding nothing. Undefined when it matches, when the header declares none, or when it is the
4512
+ * TMX `*all*` wildcard, which declares that the source varies per unit.
4513
+ */
4514
+ readonly sourceLanguageMismatch: string | undefined;
4515
+ /** Units carrying no segment in the configured source locale, so nothing could be keyed. */
4516
+ readonly unmatchedSourceUnits: number;
4517
+ /**
4518
+ * Units carrying two segments of equal standing that resolve to the configured source locale with
4519
+ * different values, such as an `en-US` "Color" and an `en-GB` "Colour" segment in a project
4520
+ * configured for `en`. An exact tag outranks a prefix match and identical values agree, so neither
4521
+ * counts here. Which value the translations belong to cannot be known, so the unit is refused
4522
+ * rather than attributed to whichever came last.
4523
+ */
4524
+ readonly conflictingSourceUnits: number;
4525
+ /** Units whose segments carried inline markup, which is flattened to its text. */
4526
+ readonly markupStrippedUnits: number;
4527
+ /**
4528
+ * Units whose inline markup carried a `sub` element, a sub-flow such as a tooltip or alternative
4529
+ * text embedded in the markup. Its text is not part of the segment's own string, so it is left out
4530
+ * of the segment rather than flattened into it.
4531
+ */
4532
+ readonly subflowDroppedUnits: number;
4533
+ /** Language tags that resolved to no configured locale, with how many units carried each. */
4534
+ readonly unmatchedLanguages: readonly TmxLanguageReport[];
4535
+ /** Language tags that two or more configured locales could claim, so none was chosen. */
4536
+ readonly ambiguousLanguages: readonly TmxLanguageReport[];
4537
+ /**
4538
+ * Segments that resolved cleanly to a configured target locale this run left out, because
4539
+ * {@link ImportTmxInput.locales} narrowed it. Nothing is wrong with them; they are reported so a
4540
+ * narrowed run does not look like a file that held less than it did.
4541
+ */
4542
+ readonly notImported: readonly TmxLanguageReport[];
4543
+ /**
4544
+ * Whether the memory file could be written. False when the project's cache was written by a newer
4545
+ * build, which is left untouched rather than downgraded.
4546
+ */
4547
+ readonly memoryWritable: boolean;
4548
+ }
4549
+ /** Input for {@link importTmx}. */
4550
+ interface ImportTmxInput {
4551
+ /** The resolved project config, normally from {@link loadConfig}. */
4552
+ readonly config: VerbatraConfig;
4553
+ /** Path to the TMX file, resolved against `cwd`. */
4554
+ readonly file: string;
4555
+ /** Directory the file and the memory are resolved against. Defaults to the process working directory. */
4556
+ readonly cwd?: string;
4557
+ /** Read and validate the file but write nothing. Defaults to false. */
4558
+ readonly dryRun?: boolean;
4559
+ /**
4560
+ * Let an imported unit replace a translation the memory already holds for the same source and
4561
+ * locale. Off by default, so the project's own memory wins a collision.
4562
+ */
4563
+ readonly overwrite?: boolean;
4564
+ /** Subset of configured target locales to import. Defaults to all of them. */
4565
+ readonly locales?: readonly string[];
4566
+ }
4567
+ /** Injectable dependencies for {@link importTmx}. Every field has a working default. */
4568
+ interface ImportTmxDeps {
4569
+ /** Format-adapter registry, used for placeholder extraction and ICU validation. Defaults to the built-in registry. */
4570
+ readonly adapterRegistry?: AdapterRegistry;
4571
+ /** File-system port. Defaults to the real file system. */
4572
+ readonly fs?: SdkFs;
4573
+ }
4574
+ /**
4575
+ * Reads a TMX translation memory into the project's own memory, so a team arriving with years of
4576
+ * accumulated translations does not start cold. It calls no provider and needs no API key: every
4577
+ * value comes from the file.
4578
+ *
4579
+ * Nothing from the file is trusted. The XML is parsed under the interchange package's hardening
4580
+ * (entity declarations refused, size and unit bounds enforced), a language tag is resolved to a
4581
+ * configured locale by an explicit rule rather than guessed at, and every candidate translation
4582
+ * must pass the same placeholder, inline markup, ICU, degeneracy and emptiness gate that a
4583
+ * provider's output and a filled translator handoff already face before it is stored. A unit that
4584
+ * fails is counted, never written, so it can never later be served as a cache hit that was never
4585
+ * validated.
4586
+ *
4587
+ * Units are stored under the project's current configuration fingerprint, the same key a real run
4588
+ * writes. That is deliberate: an imported memory is reused exactly when the configuration that
4589
+ * would consume it matches, and stops matching when the provider, model, tone or glossary changes,
4590
+ * which is the protection the fingerprint layer exists to give. Re-import after such a change.
4591
+ *
4592
+ * A collision is decided in favour of what the project already has: if the memory already holds a
4593
+ * different translation for the same source and locale, the imported one is refused and counted as
4594
+ * `kept` unless {@link ImportTmxInput.overwrite} is set. Importing the same file twice therefore
4595
+ * changes nothing the second time, and writes no file at all.
4596
+ *
4597
+ * One consequence is worth stating plainly, because no import-time check can police it. An accepted
4598
+ * unit's source text is written into the memory's source index, which is the same index fuzzy reuse
4599
+ * scores a changed string against. An imported source that DIFFERS from a string in the project can
4600
+ * therefore be served for that string by fuzzy reuse, which is what resemblance means.
4601
+ *
4602
+ * An imported source that is identical to a project string but hashes differently is not reachable
4603
+ * at all. Fuzzy reuse discards any candidate whose normalized source equals the query, so a project
4604
+ * entry that carries a description, a meaning, or a plural flag the unit cannot carry falls through
4605
+ * to the provider rather than being served: the hashes differ, and the identical text disqualifies
4606
+ * the fuzzy candidate.
4607
+ *
4608
+ * A fuzzy reuse is still held to the integrity gate against the real entry and is reported as a
4609
+ * `FUZZY_CACHE_REUSE` review flag on the run summary, so it is visible rather than silent. A project
4610
+ * that does not want an imported memory reachable that way should leave `fuzzyCache` out of its
4611
+ * config.
4612
+ *
4613
+ * @param input - The config, the file path, and the dry-run, overwrite and locale-subset switches.
4614
+ * @param deps - Optional adapter registry and file-system overrides.
4615
+ * @returns What was read, what was stored, and everything that was skipped or refused.
4616
+ *
4617
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`: no adapter is registered for the configured format.
4618
+ * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
4619
+ * @throws {@link SdkError} `SOURCE_UNREADABLE`: no file exists at the given path.
4620
+ * @throws {@link SdkError} `SOURCE_INVALID`: the file is oversized, malformed, not a TMX document,
4621
+ * or declares an XML entity. When the problem has a place in the file, the message names its line,
4622
+ * column and, inside a translation unit, the unit's 1-based ordinal, and `cause` is the interchange
4623
+ * reader's error carrying the same as a structured `location`.
4624
+ *
4625
+ * @example
4626
+ * ```ts
4627
+ * const result = await importTmx({ config, file: "legacy-memory.tmx" });
4628
+ * for (const locale of result.locales) {
4629
+ * console.log(`${locale.locale}: ${locale.added} added, ${locale.kept} kept`);
4630
+ * }
4631
+ * ```
4632
+ */
4633
+ declare function importTmx(input: ImportTmxInput, deps?: ImportTmxDeps): Promise<ImportTmxResult>;
4634
+
2275
4635
  /**
2276
4636
  * Whatever could be read about the process currently holding a write lock. Both fields are optional
2277
4637
  * because a lock file left behind by a killed process may be truncated or unreadable, and reporting
@@ -2391,6 +4751,20 @@ interface TranslateInput {
2391
4751
  * preview work without spending anything. Defaults to false.
2392
4752
  */
2393
4753
  readonly dryRun?: boolean;
4754
+ /**
4755
+ * Compute a pre-run cost estimate and return it on {@link RunSummary.estimate}. Implies
4756
+ * `dryRun`: an estimate constructs no provider, reads no API key, makes no network call, and
4757
+ * writes no file, so it is safe to run anywhere. Defaults to false.
4758
+ *
4759
+ * The figure bounds the plan rather than the invoice: every provider call a live run schedules is
4760
+ * counted, plural generation included, and the prompt is measured from the request payload that
4761
+ * would be sent. Provider-side retries, a repair round, and a language that expands past the
4762
+ * allowance can each push a live run above it; {@link EstimateCaveatCode} names them all. It
4763
+ * carries a currency amount only when the config supplies a `rates` block covering the configured
4764
+ * provider and model; otherwise it reports the quantity and says why the money is missing. See
4765
+ * {@link RunEstimate}.
4766
+ */
4767
+ readonly estimate?: boolean;
2394
4768
  /**
2395
4769
  * Remove keys that no longer exist in the source. Defaults to the config's `prune`, then to
2396
4770
  * false, so orphaned keys are reported but kept unless removal is asked for explicitly.
@@ -2412,8 +4786,9 @@ interface TranslateInput {
2412
4786
  readonly lockAcquireTimeoutMs?: number;
2413
4787
  /**
2414
4788
  * How many locales to run at once. Must be an integer of at least 1; defaults to 1. On a live
2415
- * run it cannot be combined with a configured token budget, because concurrent locales would
2416
- * overshoot the budget nondeterministically.
4789
+ * run it cannot be combined with a configured token budget: the ceiling would still hold, but
4790
+ * which locale loses its remaining work would depend on the order the locales interleave, so the
4791
+ * run would not be reproducible.
2417
4792
  */
2418
4793
  readonly concurrency?: number;
2419
4794
  /**
@@ -2431,6 +4806,29 @@ interface TranslateDeps {
2431
4806
  /** File-system port. Defaults to the real file system. */
2432
4807
  readonly fs?: SdkFs;
2433
4808
  }
4809
+ /**
4810
+ * Whether a set of run options resolves to a dry run. `estimate` implies `dryRun`, and this is the
4811
+ * one place that implication is decided: {@link translate} calls it, and so should any caller that
4812
+ * has to know before the run starts whether anything will be written or spent, rather than
4813
+ * re-deriving the rule and drifting from it.
4814
+ *
4815
+ * @param input - The `dryRun` and `estimate` options as the caller received them.
4816
+ * @returns True when the run will construct no provider, write no file, and spend nothing.
4817
+ *
4818
+ * @example
4819
+ * ```ts
4820
+ * import { resolveDryRun, translate } from "@verbatra/sdk";
4821
+ *
4822
+ * if (!resolveDryRun(options)) {
4823
+ * await prepareWorkspaceForWrites();
4824
+ * }
4825
+ * const summary = await translate({ config, ...options });
4826
+ * ```
4827
+ */
4828
+ declare function resolveDryRun(input: {
4829
+ readonly dryRun?: boolean | undefined;
4830
+ readonly estimate?: boolean | undefined;
4831
+ }): boolean;
2434
4832
  /**
2435
4833
  * Runs the one-shot translation flow over every configured target locale, or over the subset named
2436
4834
  * by `locales`: read the source, diff
@@ -2505,13 +4903,6 @@ interface TranslateDeps {
2505
4903
  */
2506
4904
  declare function translate(input: TranslateInput, deps?: TranslateDeps): Promise<RunSummary>;
2507
4905
 
2508
- /**
2509
- * The two delimiter-separated interchange formats. They are one implementation with one parameter, the
2510
- * field delimiter: `tsv` is not a second format, it is `csv` with a tab. The member is also the file
2511
- * extension an export writes and an import reads back.
2512
- */
2513
- type DelimitedFormat = "csv" | "tsv";
2514
-
2515
4906
  /**
2516
4907
  * The file shape of a translator handoff: a single styled `.xlsx` workbook with one sheet per
2517
4908
  * locale, or one plain `.csv` or `.tsv` file per locale.
@@ -2618,9 +5009,10 @@ interface ExportWorkbookResult {
2618
5009
  * unsupported version.
2619
5010
  * @throws {@link SdkError} `UNKNOWN_LOCALE`: a requested locale is not a configured target locale.
2620
5011
  * @throws The underlying file-system error, unwrapped, when the handoff could not be written: `out`
2621
- * names a directory that does not exist or is not writable, or the device is full. Branch on the
2622
- * Node `code`, such as `ENOENT`, rather than on the message, which can name the internal temporary
2623
- * file the atomic write uses.
5012
+ * resolves to a location the process lacks permission to write, or the device is full. A missing
5013
+ * output directory is created automatically and is not a cause. Branch on the Node `code`, such as
5014
+ * `EACCES` or `ENOSPC`, rather than on the message, which can name the internal temporary file the
5015
+ * atomic write uses.
2624
5016
  */
2625
5017
  declare function exportWorkbook(input: ExportWorkbookInput, deps?: ExportWorkbookDeps): Promise<ExportWorkbookResult>;
2626
5018
 
@@ -2721,6 +5113,12 @@ interface LocalePathResolverConfig {
2721
5113
  readonly sourceLocale: string;
2722
5114
  /** Every target locale. All of them are mapped up front so collisions are caught immediately. */
2723
5115
  readonly targetLocales: readonly string[];
5116
+ /**
5117
+ * The configured format. Every format maps each locale to a distinct path except a
5118
+ * shared-catalogue format (one holding every locale in a single file, such as `apple-xcstrings`),
5119
+ * which maps every locale to the same path.
5120
+ */
5121
+ readonly format: FormatId$1;
2724
5122
  /** The file layout. */
2725
5123
  readonly files: {
2726
5124
  /** The path pattern, which must contain the `{locale}` token. */
@@ -2740,6 +5138,9 @@ interface LocalePathResolver {
2740
5138
  * The configured locale owning an absolute path, or `undefined` when the path belongs to no
2741
5139
  * configured locale. The path is normalized before lookup, so a relative or non-canonical path
2742
5140
  * still resolves.
5141
+ *
5142
+ * Always `undefined` for a shared-catalogue format: every configured locale resolves to the same
5143
+ * path there, so no single locale owns it.
2743
5144
  */
2744
5145
  localeFor(absolutePath: string): string | undefined;
2745
5146
  }
@@ -2755,15 +5156,21 @@ interface LocalePathResolver {
2755
5156
  * path collision is reported here, before any file is read and before any provider is called,
2756
5157
  * rather than partway through a run.
2757
5158
  *
5159
+ * For a shared-catalogue format (such as `apple-xcstrings`), `pathFor` resolves every locale to the
5160
+ * same path (the `{locale}` token is still required in the pattern but substitutes to the empty
5161
+ * string), no locale style is applied, and `localeFor` always returns `undefined` rather than
5162
+ * reporting a collision: every configured locale sharing one path is the expected shape for these
5163
+ * formats, not a configuration mistake.
5164
+ *
2758
5165
  * @param cwd - Directory the pattern is resolved against.
2759
- * @param config - The source locale, target locales, and file layout.
5166
+ * @param config - The source locale, target locales, format, and file layout.
2760
5167
  * @returns A resolver mapping locales to paths and paths back to locales.
2761
5168
  *
2762
5169
  * @throws {@link SdkError} `LOCALE_LAYOUT_INVALID`: the pattern lacks the `{locale}` token, a
2763
5170
  * segment style's token does not stand alone between separators, or a configured locale has no
2764
5171
  * valid single-segment spelling under the declared style.
2765
5172
  * @throws {@link SdkError} `LOCALE_PATH_COLLISION`: two configured locales resolve to the same
2766
- * absolute path.
5173
+ * absolute path, for a format that is not a shared-catalogue format.
2767
5174
  */
2768
5175
  declare function createLocalePathResolver(cwd: string, config: LocalePathResolverConfig): LocalePathResolver;
2769
5176
 
@@ -2818,6 +5225,33 @@ declare function loadLockFile(input?: LoadLockFileInput, deps?: LoadLockFileDeps
2818
5225
  */
2819
5226
  declare const LOCK_FILE_NAME = "verbatra.lock.json";
2820
5227
 
5228
+ /**
5229
+ * Scrubs provider API key shapes and any currently configured provider environment variable value
5230
+ * out of a string, replacing each match with `[REDACTED]`.
5231
+ *
5232
+ * Two independent passes run: a set of shape patterns for the major providers (OpenAI-style `sk-`
5233
+ * keys, Gemini-style `AIza` keys, and hex UUID-shaped keys, with or without a `:fx` suffix), and an
5234
+ * exact-value scrub of whatever `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`,
5235
+ * `DEEPL_API_KEY`, `GOOGLE_TRANSLATE_API_KEY`, or `OPENAI_COMPATIBLE_API_KEY` currently holds in
5236
+ * the process environment. Use this on any text a surface returns to a caller that did not itself
5237
+ * generate that text, such as a file path, a config value, or an upstream error message, so a key
5238
+ * value already present in the environment or written by a user can never reach an agent, a
5239
+ * browser tab, or a log line.
5240
+ *
5241
+ * @param text - The text to scrub.
5242
+ * @returns The same text with every matching key shape and configured key value replaced by
5243
+ * `[REDACTED]`.
5244
+ *
5245
+ * @example
5246
+ * ```ts
5247
+ * import { redact } from "@verbatra/sdk";
5248
+ *
5249
+ * redact("key is sk-abcdEFGH12345678 in the log");
5250
+ * // "key is [REDACTED] in the log"
5251
+ * ```
5252
+ */
5253
+ declare function redact(text: string): string;
5254
+
2821
5255
  /**
2822
5256
  * A provider that project scaffolding can offer out of the box. It excludes `openai-compatible`,
2823
5257
  * which needs a `baseUrl` and a model name that only the user can supply, so there is nothing
@@ -2840,8 +5274,12 @@ declare const scaffoldingMetadata: {
2840
5274
  readonly openai: "OPENAI_API_KEY";
2841
5275
  readonly gemini: "GEMINI_API_KEY";
2842
5276
  readonly deepl: "DEEPL_API_KEY";
5277
+ readonly "google-translate": "GOOGLE_TRANSLATE_API_KEY";
2843
5278
  };
2844
- /** A reasonable default model to prefill per language-model provider. DeepL has none, since it takes no model. */
5279
+ /**
5280
+ * A reasonable default model to prefill per language-model provider. DeepL and Google Cloud
5281
+ * Translation have none, since neither takes a model.
5282
+ */
2845
5283
  readonly scaffoldModels: {
2846
5284
  readonly anthropic: "claude-sonnet-4-6";
2847
5285
  readonly openai: "gpt-5.4-mini";
@@ -2852,7 +5290,7 @@ declare const scaffoldingMetadata: {
2852
5290
  * not agree: Anthropic calls it `maxTokens` and the others `maxOutputTokens`. A generator that
2853
5291
  * prefills a token limit must read the key from here rather than assume one, because
2854
5292
  * {@link verbatraConfigSchema} validates each provider's options strictly and rejects the wrong
2855
- * one. DeepL has no entry, since it takes no token limit.
5293
+ * one. DeepL and Google Cloud Translation have no entry, since neither takes a token limit.
2856
5294
  */
2857
5295
  readonly providerTokenLimitKeys: {
2858
5296
  readonly anthropic: "maxTokens";
@@ -2860,7 +5298,7 @@ declare const scaffoldingMetadata: {
2860
5298
  readonly gemini: "maxOutputTokens";
2861
5299
  };
2862
5300
  /** Every i18n file format the SDK can read and write. */
2863
- readonly supportedFormats: readonly ["i18next-json", "vue-i18n-json", "next-intl-json", "ngx-translate-json", "xliff", "yaml", "arb", "properties"];
5301
+ readonly supportedFormats: readonly ["i18next-json", "vue-i18n-json", "next-intl-json", "ngx-translate-json", "xliff", "yaml", "arb", "properties", "apple-strings", "apple-xcstrings", "android-xml", "gettext-po", "ini", "resx"];
2864
5302
  };
2865
5303
 
2866
5304
  /**
@@ -3005,4 +5443,4 @@ interface WatchController {
3005
5443
  */
3006
5444
  declare function watch(input: WatchInput, deps?: WatchDeps): Promise<WatchController>;
3007
5445
 
3008
- 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 };
5446
+ export { AdapterError, type AdapterErrorCode, type AdapterFs, AdapterRegistry, type AdapterResolution, type AddedKey, type BillingUnit, type BoundedReadOutcome, type BudgetBehavior, type BudgetStanding, type BuildWriteTree, CACHE_FILE_NAME, type CharacterRate, type CharacterRunQuantity, type CheckDeps, type CheckInput, type CheckSummary, type ComparePlaceholders, type ComputeInvalidIcuKeys, type ConfigSource, type CreateProvider, type CreateWatcher, type CustomFormatId$1 as CustomFormatId, DEFAULT_DELIMITED_PATH, DEFAULT_EXCHANGE_FORMAT, DEFAULT_TMX_PATH, DEFAULT_TYPES_PATH, DEFAULT_WORKBOOK_PATH, type DeriveDescriptions, type DeriveEntry, type DiffDeps, type DiffInput, type DiffSummary, type DirectoryEntry, type DoctorCheck, type DoctorCheckId, type DoctorCheckStatus, type DoctorDeps, type DoctorInput, type DoctorResult, type DuplicateKeyReport, EXCHANGE_FORMATS, type EditEntryDeps, type EditEntryInput, type EditEntryResult, type EstimateCaveatCode, type EstimateIdentity, type EstimatePricing, type ExchangeFormat, type ExportTmxDeps, type ExportTmxInput, type ExportTmxLocaleCount, type ExportTmxResult, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type ExtractDeps, type ExtractInput, type ExtractPlaceholders, type ExtractResult, type ExtractionConfig, type FlatFileAdapterOptions, type FlatParseOutcome, type FlatParseResult, type FormatAdapter, type FormatId$1 as FormatId, type FuzzyCacheHit, type GenerateTypesDeps, type GenerateTypesInput, type GenerateTypesResult, type GlossaryFileDeps, type GlossaryFileInput, type GlossaryProvenance, INTEGRITY_GATE_REASONS, type ImportTmxDeps, type ImportTmxInput, type ImportTmxLocaleResult, type ImportTmxResult, type ImportWorkbookDeps, type ImportWorkbookInput, type InconsistencyGroup, type InconsistentTranslation, type IntegrityGateReason, type JsonLeaf, type JsonRecord, type JsonTree, type KeyConflict, type KeyIntegrityDeps, type KeyIntegrityEntry, type KeyIntegrityInput, type KeyMode, type KeyValueDeps, type KeyValueInput, type KeyValuePair, type KeyValueResult, LOCK_FILE_NAME, type LiteralFinding, type LiteralScan, type LiteralSuppressionReason, type LoadConfigOptions, type LoadLockFileDeps, type LoadLockFileInput, type LoadedConfig, type LocaleCheckSummary, type LocaleDiff, type LocaleEstimate, type LocaleEstimateQuantity, type LocaleFileSnapshot, type LocaleFinishedEvent, type LocaleKeyIntegrity, type LocaleNotice, type LocalePathResolver, type LocalePathResolverConfig, type LocaleResource$1 as LocaleResource, type LocaleSnapshotDelta, type LocaleStartedEvent, type LocaleStyle, type LocaleSummary, type LocaleValues, type LocaleValuesDeps, type LocaleValuesInput, type LockFile, type LockHolder, type LockLocaleState, type LockStateDeps, type LockStateInput, type LockStateResult, type LockWaitEvent, type LockWaitListener, type MalformedRowReport, type ModelRate, type NeedsReviewEntry, type OrderedRecord, type OrderedValue, type PlaceholderIntegrityResult$1 as PlaceholderIntegrityResult, type PossiblyDynamicKey, type PricedLocaleEstimate, type PricedRunEstimate, type ProgressEvent, type ProgressListener, type ProviderBilling, type ProviderConfig, type ProviderId, type PseudolocalizeDeps, type PseudolocalizeInput, type PseudolocalizeResult, REVIEW_REASON_CODES, type RateCard, type ReadLocaleFileSnapshotDeps, type ReadLocaleFileSnapshotInput, type ReadResult, type ResolveOptions, type RetranslateEntryDeps, type RetranslateEntryInput, type RetranslateEntryResult, type ReviewReasonCode, type RunBudget, type RunEstimate, type RunEstimateQuantity, type RunFinishedEvent, type RunStatusDeps, type RunStatusFile, type RunStatusInput, type RunStatusLocale, type RunStatusResult, type RunSummary, type RunTranslate, type ScaffoldableProviderId, type ScanDiagnostic, type ScanDiagnosticReason, SdkError, type SdkErrorCode, type SdkFs, type SdkNotice, type SdkNoticeCode, type Sniff, type SourceFramework, type SourceLocation, type SubBatchProgressEvent, type SupportedFormat$1 as SupportedFormat, type SuppressedLiteral, type TmxErrorLocation, type TmxLanguageReport, type TmxRejectionCounts, type TmxRejectionReason, type TokenRate, type TokenRunQuantity, type TranslateDeps, type TranslateInput, type TranslationEntry$1 as TranslationEntry, type TranslationMemory, type TreeFileAdapterOptions, type UnpricedLocaleEstimate, type UnpricedRunEstimate, type UnresolvedArgumentReason, type UnresolvedMessage, type UnusedKey, type UnusedKeysNotRun, type UnusedKeysNotRunReason, type UnusedKeysPrefixSite, type UnusedKeysReport, type UnusedKeysScan, type UnusedKeysSite, type UnusedKeysUnreliability, type UnusedKeysUnreliableReason, type UpdateGlossaryTermInput, type UsageSummary, type ValidateMessage, type ValidateTree, type VerbatraConfig, type VerbatraConfigInput, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, budgetStanding, check, createDefaultRegistry, createFlatFileAdapter, createLocalePathResolver, createTreeFileAdapter, defineConfig, diff, diffLocaleSnapshots, doctor, editEntry, exportTmx, exportWorkbook, extract, generateTypes, importTmx, importWorkbook, isCustomFormatId, keyIntegrity, keyValue, loadConfig, loadConfigWithMeta, loadLockFile, localeValues, lockState, nodeAdapterFs, pseudolocalize, readGlossaryFile, readLocaleFileSnapshot, redact, resolveDryRun, retranslateEntry, runStatus, scaffoldingMetadata, tmxErrorLocation, translate, updateGlossaryTerm, verbatraConfigSchema, watch };