@verbatra/sdk 0.3.0 → 0.4.1

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
@@ -1,15 +1,191 @@
1
- import { AnthropicModel, OpenAiModel, GeminiModel, ProviderNotice, TranslationProvider } from '@verbatra/ai-providers';
2
1
  import { z } from 'zod';
3
- import { AdapterRegistry } from '@verbatra/format-adapters';
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+ import { Interactions } from '@google/genai';
4
+ import OpenAI from 'openai';
4
5
 
6
+ /** Zod schema accepting exactly one of {@link SUPPORTED_FORMATS}. */
7
+ declare const supportedFormatSchema$1: z.ZodEnum<{
8
+ "i18next-json": "i18next-json";
9
+ "vue-i18n-json": "vue-i18n-json";
10
+ "next-intl-json": "next-intl-json";
11
+ "ngx-translate-json": "ngx-translate-json";
12
+ xliff: "xliff";
13
+ yaml: "yaml";
14
+ arb: "arb";
15
+ }>;
16
+ /** One of the supported source formats; a member of {@link SUPPORTED_FORMATS}. */
17
+ type SupportedFormat$1 = z.infer<typeof supportedFormatSchema$1>;
18
+
19
+ /**
20
+ * A single, format-neutral translation unit. Placeholders are supplied already
21
+ * extracted; core never derives them from the value.
22
+ */
23
+ declare const translationEntrySchema$1: z.ZodObject<{
24
+ key: z.ZodString;
25
+ namespace: z.ZodString;
26
+ value: z.ZodString;
27
+ description: z.ZodOptional<z.ZodString>;
28
+ meaning: z.ZodOptional<z.ZodString>;
29
+ placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
30
+ isPlural: z.ZodBoolean;
31
+ }, z.core.$strip>;
32
+ /** The validated shape of one translation unit; the inferred type of {@link translationEntrySchema}. */
33
+ type TranslationEntry$1 = Readonly<z.infer<typeof translationEntrySchema$1>>;
34
+
35
+ /**
36
+ * Result of comparing source placeholders against translated-output placeholders as MULTISETS
37
+ * (counts matter). Always returned (never thrown) for an ordinary mismatch.
38
+ */
39
+ interface PlaceholderIntegrityResult {
40
+ /** True when nothing is missing or extra and order is preserved. */
41
+ readonly matches: boolean;
42
+ /** Occurrences present in source but absent from the translation, one per dropped occurrence. Sorted. */
43
+ readonly missing: readonly string[];
44
+ /** Occurrences present in the translation but absent from source, one per surplus occurrence. Sorted. */
45
+ readonly extra: readonly string[];
46
+ /** Same multiset present in both, but in a different order. */
47
+ readonly reordered: boolean;
48
+ }
49
+
50
+ /** A provider is either a prompt-driven LLM or a dedicated machine-translation API. */
51
+ type ProviderKind = "llm" | "machine-translation";
52
+ /** Target tone for a translation. Maps to formality for machine-translation providers. */
53
+ type Tone = "formal" | "informal" | "neutral";
54
+ /**
55
+ * Produces the placeholder set of a value for the output integrity check. Supplied
56
+ * by the caller (the SDK) so it matches the entries' format; ai-providers never
57
+ * derives placeholders itself.
58
+ */
59
+ type PlaceholderExtractor = (value: string) => readonly string[];
60
+ /**
61
+ * A batch translation request. Format- and provider-neutral: it carries no prompt,
62
+ * model, key, or other provider-specific field. The placeholder extractor is
63
+ * mandatory (see validateRequest).
64
+ */
65
+ interface TranslateRequest {
66
+ /** BCP-47 source locale of the entries (for example, "en"). */
67
+ readonly sourceLocale: string;
68
+ /** BCP-47 target locale to translate into (for example, "de"). */
69
+ readonly targetLocale: string;
70
+ /** The entries to translate; at least one is required. */
71
+ readonly entries: readonly TranslationEntry$1[];
72
+ /** Optional source-term to target-term map applied by glossary-capable providers. */
73
+ readonly glossary?: Readonly<Record<string, string>>;
74
+ /** Optional target tone; machine-translation providers map it to formality. */
75
+ readonly tone?: Tone;
76
+ /** Mandatory placeholder extractor; the output integrity check runs against it. */
77
+ readonly extractPlaceholders: PlaceholderExtractor;
78
+ }
79
+ /** Token usage, when the provider reports it. Absent for providers without tokens (DeepL). */
80
+ interface Usage {
81
+ readonly inputTokens: number;
82
+ readonly outputTokens: number;
83
+ }
84
+ /** Result of a batch translation: per-key values and per-key integrity outcomes. */
85
+ interface TranslateResult {
86
+ /** The translated value for each requested key. */
87
+ readonly values: ReadonlyMap<string, string>;
88
+ /** The placeholder-integrity outcome for each key (source vs translated placeholder sets). */
89
+ readonly integrity: ReadonlyMap<string, PlaceholderIntegrityResult>;
90
+ /** Token usage when the provider reports it; absent for token-less providers. */
91
+ readonly usage?: Usage;
92
+ }
5
93
  /**
6
- * The provider section of the config: a discriminated union over the provider id,
7
- * reusing each provider's own exported config schema. There is no key field anywhere
8
- * in this union. The provider reads its API key from the environment at construction.
94
+ * The single contract every provider implements. It is narrow enough that a machine-translation API like
95
+ * DeepL fits it directly, while LLM providers implement it by delegating to {@link runLlmTranslation}.
96
+ * A new provider attaches by implementing this and registering it in a {@link ProviderRegistry}.
9
97
  *
10
- * This union and the factory table below are co-located on purpose: adding a provider
11
- * is a single edit here (one union variant plus one table entry), and the mapped-type
12
- * table makes the two sets provably identical at compile time.
98
+ * Implementer invariants:
99
+ * - Translatable strings are UNTRUSTED. They travel only as data to the provider; never splice them into
100
+ * instruction text, and never act on instructions a value appears to contain.
101
+ * - Read the API key ONLY from the environment (inside the SDK client). The request, config, and this
102
+ * interface never carry a key.
103
+ * - Fail with a secret-free {@link ProviderError}: never bind, log, or re-throw raw SDK error text (it can
104
+ * carry a key or request headers). Validate the request at the boundary with `validateRequest` so the
105
+ * integrity check can never be skipped.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * // A machine-translation provider implements translateBatch directly (the DeepL shape).
110
+ * function createMyMtProvider(client: MyClient): TranslationProvider {
111
+ * return {
112
+ * id: "my-mt",
113
+ * kind: "machine-translation",
114
+ * supportsGlossary: false,
115
+ * async translateBatch(request) {
116
+ * const data = validateRequest(request); // throws INVALID_REQUEST on a bad request
117
+ * const texts = data.entries.map((e) => e.value);
118
+ * const out = await client.translate(texts, data.targetLocale); // SDK reads MY_API_KEY from env
119
+ * // map out -> values, run the integrity check, return { values, integrity }
120
+ * return buildResult(data, out, request.extractPlaceholders);
121
+ * },
122
+ * };
123
+ * }
124
+ * ```
125
+ */
126
+ interface TranslationProvider {
127
+ /** A stable identifier for this provider (for example, "anthropic", "deepl"). */
128
+ readonly id: string;
129
+ /** Whether this provider is a prompt-driven LLM or a dedicated machine-translation API. */
130
+ readonly kind: ProviderKind;
131
+ /** Whether this provider applies a configured glossary. */
132
+ readonly supportsGlossary: boolean;
133
+ /**
134
+ * Translate a batch of entries.
135
+ *
136
+ * @param request - The provider-neutral batch request (no prompt, model, or key).
137
+ * @returns The per-key translated values and per-key placeholder-integrity outcomes.
138
+ * @throws {@link ProviderError}, secret-free, with the code for the failure (the concrete codes are
139
+ * the implementation's; see each provider factory).
140
+ */
141
+ translateBatch(request: TranslateRequest): Promise<TranslateResult>;
142
+ }
143
+
144
+ /**
145
+ * The Anthropic authoring model type, sourced from the SDK's own model union. It is an
146
+ * open union (known literals plus `string & {}`), so unknown or newly released model IDs
147
+ * are still accepted. This drives editor completions only; the runtime schema validates.
148
+ */
149
+ type AnthropicModel = Anthropic.Messages.Model;
150
+ /**
151
+ * Stable codes for the DeepL graceful-degradation notices, DeepL only. These are returned DATA on a
152
+ * successful result, NOT thrown:
153
+ *
154
+ * - `FORMALITY_DOWNGRADED`: a requested formality was not applied (the free tier does not support it).
155
+ * - `GLOSSARY_IGNORED`: a supplied generic glossary was not applied.
156
+ */
157
+ type ProviderNoticeCode = "FORMALITY_DOWNGRADED" | "GLOSSARY_IGNORED";
158
+ /**
159
+ * An observable, structured signal that something was gracefully degraded (not an
160
+ * error). Carries only a stable code and a static message, never a key or content.
161
+ * Surfaced as result data, never thrown; callers inspect it but need not treat it as a failure.
162
+ */
163
+ interface ProviderNotice {
164
+ /** The stable {@link ProviderNoticeCode} for what was degraded. */
165
+ readonly code: ProviderNoticeCode;
166
+ /** A static, safe description; never a key or translatable content. */
167
+ readonly message: string;
168
+ }
169
+
170
+ /**
171
+ * The Gemini authoring model type, sourced from the installed SDK's own model union so
172
+ * verbatra restates no model IDs. It is an open union, so unknown model IDs are still
173
+ * accepted. Type-only: it informs editor completions and is never validated at runtime,
174
+ * where the schema stays `z.string().min(1)`.
175
+ */
176
+ type GeminiModel = Interactions.Model;
177
+
178
+ /**
179
+ * The OpenAI authoring model type, sourced from the OpenAI SDK's published chat-model
180
+ * union. It drives editor completions only; it is never read at runtime, and the
181
+ * runtime schema stays `z.string().min(1)`.
182
+ */
183
+ type OpenAiModel = OpenAI.ChatModel;
184
+
185
+ /**
186
+ * The provider section of the config: a discriminated union over the provider id, reusing each
187
+ * provider's own config schema. There is no key field anywhere in this union; the provider reads its
188
+ * API key from the environment at construction.
13
189
  */
14
190
  declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
15
191
  id: z.ZodLiteral<"anthropic">;
@@ -38,6 +214,11 @@ declare const providerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
38
214
  type ProviderConfig = z.infer<typeof providerConfigSchema>;
39
215
  type ProviderId = ProviderConfig["id"];
40
216
 
217
+ /**
218
+ * The verbatra project configuration. It carries no API key (the provider reads its key from the
219
+ * environment), and unknown top-level keys are rejected so a stray secret cannot hide in it. Validated
220
+ * with zod at the boundary regardless of where it was loaded from.
221
+ */
41
222
  declare const verbatraConfigSchema: z.ZodObject<{
42
223
  sourceLocale: z.ZodString;
43
224
  targetLocales: z.ZodArray<z.ZodString>;
@@ -46,6 +227,9 @@ declare const verbatraConfigSchema: z.ZodObject<{
46
227
  "vue-i18n-json": "vue-i18n-json";
47
228
  "next-intl-json": "next-intl-json";
48
229
  "ngx-translate-json": "ngx-translate-json";
230
+ xliff: "xliff";
231
+ yaml: "yaml";
232
+ arb: "arb";
49
233
  }>;
50
234
  files: z.ZodObject<{
51
235
  pattern: z.ZodString;
@@ -86,16 +270,6 @@ declare const verbatraConfigSchema: z.ZodObject<{
86
270
  }, z.core.$strict>;
87
271
  type VerbatraConfig = z.infer<typeof verbatraConfigSchema>;
88
272
 
89
- /**
90
- * The closed set of a provider's known model IDs: exactly the string literals its SDK
91
- * model type ships, with the SDK's own open `string & {}` arm stripped out. Distributing
92
- * over the union and dropping any arm the wide `string` extends leaves only the literals,
93
- * so the authoring field offers and ACCEPTS only the selected provider's known models. A
94
- * foreign or unknown model (for example a Claude model under `id: "gemini"`) is a type
95
- * error at authoring time. This is a static authoring constraint only; the runtime schema
96
- * stays `z.string().min(1)` and still accepts any non-empty string, so a brand-new model
97
- * the installed SDK does not yet list is flagged in the editor but still runs.
98
- */
99
273
  type KnownModels<M extends string> = M extends string ? (string extends M ? never : M) : never;
100
274
  type AuthoringVariant<Id extends ProviderId, M extends string> = Extract<ProviderConfig, {
101
275
  id: Id;
@@ -108,13 +282,6 @@ type AuthoringVariant<Id extends ProviderId, M extends string> = Extract<Provide
108
282
  model: KnownModels<M>;
109
283
  };
110
284
  } : never : never;
111
- /**
112
- * The authoring view of one provider variant, keyed by id. LLM providers (anthropic,
113
- * openai, gemini) restrict `options.model` to that provider's known model literals; DeepL
114
- * has no model field and is carried through unchanged. Keying by id (rather than a flat
115
- * union) lets {@link AuthoringConfigFor} select exactly one variant for a literal id, so
116
- * the `options.model` site is one provider's literal set and never a multi-provider union.
117
- */
118
285
  type AuthoringProviderVariant = {
119
286
  anthropic: AuthoringVariant<"anthropic", AnthropicModel>;
120
287
  openai: AuthoringVariant<"openai", OpenAiModel>;
@@ -124,20 +291,10 @@ type AuthoringProviderVariant = {
124
291
  }>;
125
292
  };
126
293
  /**
127
- * The authoring view of the whole config for a given provider id. It is structurally a
128
- * {@link VerbatraConfig} whose `provider` is the single authoring variant for `TId`.
129
- *
130
- * When `TId` is a single provider literal, `provider` is one concrete variant, so
131
- * `options.model` is that provider's known model literals alone and not a union across
132
- * providers. `defineConfig` declares one overload per provider parameterized on this type
133
- * (`AuthoringConfigFor<"openai">` and so on), so overload resolution picks the variant from
134
- * the `provider.id` literal. That, together with the closed {@link KnownModels} set, makes a
135
- * foreign model (for example a Claude model under `id: "gemini"`) a type error and aims to
136
- * keep editors with weaker discriminated-union narrowing (for example the JetBrains/WebStorm
137
- * completion engine) offering only the selected provider's models, since each overload's
138
- * parameter is already a single variant with no nested union to narrow. When `TId` defaults
139
- * to `ProviderId`, `provider` is the full authoring union. Every value assignable here is
140
- * assignable to {@link VerbatraConfig}, because a model literal is a subtype of `string`.
294
+ * The authoring view of the whole config for a provider id: a {@link VerbatraConfig} whose `provider`
295
+ * is that id's single authoring variant, so `options.model` offers only that provider's models. When
296
+ * `TId` defaults to `ProviderId`, `provider` is the full authoring union. Every value here is assignable
297
+ * to {@link VerbatraConfig}, since a model literal is a subtype of `string`.
141
298
  */
142
299
  type AuthoringConfigFor<TId extends ProviderId = ProviderId> = Omit<VerbatraConfig, "provider"> & {
143
300
  provider: AuthoringProviderVariant[TId];
@@ -150,30 +307,16 @@ type AuthoringConfigFor<TId extends ProviderId = ProviderId> = Omit<VerbatraConf
150
307
  type AuthoringConfig = AuthoringConfigFor;
151
308
 
152
309
  /**
153
- * Identity helper for authoring a code-defined verbatra.config.ts. It returns its
154
- * argument unchanged; its only purpose is to give the author full type inference and
155
- * editor autocomplete on the config object.
156
- *
157
- * It is declared as one overload per provider id rather than a single generic. Each
158
- * overload's parameter is the concrete single-provider authoring config
159
- * ({@link AuthoringConfigFor}), so `provider.options.model` is already that one provider's
160
- * known model literals, with no union across providers and no generic for an editor to
161
- * infer. Overload resolution selects the matching overload from the `provider.id` literal,
162
- * so the editor offers only the selected provider's models and a foreign or unknown model
163
- * (for example a Claude model under `id: "gemini"`) is a type error. This is deliberately
164
- * overload-based: a generic whose type parameter is inferred from a nested `provider.id`
165
- * collapses correctly in tsserver but not in editors with weaker inference (notably the
166
- * JetBrains/WebStorm completion engine), which then fall back to the full union and offer
167
- * every provider's models. Concrete per-provider signatures avoid that inference step.
310
+ * Identity helper for authoring a typed `verbatra.config.ts`. It returns its argument unchanged; its
311
+ * only purpose is full type inference and editor autocomplete on the config object, including
312
+ * completion of the selected provider's known model literals.
168
313
  *
169
- * The final overload accepts the full {@link AuthoringConfig} union, so a config whose
170
- * provider id is not a single literal (for example a value typed as the union) still
171
- * type-checks.
314
+ * The model restriction is a static authoring constraint only: `loadConfig` validates `model` as a
315
+ * non-empty string, so a model the installed provider SDK does not yet list is flagged in the editor
316
+ * but still runs.
172
317
  *
173
- * The return type is the runtime {@link VerbatraConfig}. The model restriction is a static
174
- * authoring constraint, not a runtime one: `loadConfig` still validates `model` as
175
- * `z.string().min(1)`, so a model the installed provider SDK does not yet list is flagged
176
- * in the editor but still runs.
318
+ * @param config - The verbatra configuration object.
319
+ * @returns The same config, typed as {@link VerbatraConfig}.
177
320
  */
178
321
  declare function defineConfig(config: AuthoringConfigFor<"anthropic">): VerbatraConfig;
179
322
  declare function defineConfig(config: AuthoringConfigFor<"openai">): VerbatraConfig;
@@ -190,12 +333,10 @@ interface LoadConfigOptions {
190
333
  */
191
334
  readonly configOverride?: unknown;
192
335
  /**
193
- * An explicit config file to load instead of searching. A relative path resolves
194
- * against `cwd` (an absolute path is used as given), then cosmiconfig's load() parses
195
- * it with the same loaders search uses (.json/.yaml/.ts), and it is zod-validated at
196
- * the boundary exactly like a searched file. A missing file is CONFIG_NOT_FOUND; a
197
- * present-but-unparseable/invalid file is CONFIG_INVALID. Precedence: configOverride
198
- * wins over configPath, which wins over search.
336
+ * An explicit config file to load instead of searching. A relative path resolves against `cwd`; an
337
+ * absolute path is used as given. Parsed and zod-validated exactly like a searched file: a missing
338
+ * file is `CONFIG_NOT_FOUND`, a present but invalid one is `CONFIG_INVALID`. Takes precedence over
339
+ * search, but `configOverride` takes precedence over it.
199
340
  */
200
341
  readonly configPath?: string;
201
342
  }
@@ -259,14 +400,313 @@ declare class SdkError extends Error {
259
400
  constructor(code: SdkErrorCode, message: string);
260
401
  }
261
402
 
403
+ /** Zod schema accepting exactly one of {@link SUPPORTED_FORMATS}. */
404
+ declare const supportedFormatSchema: z.ZodEnum<{
405
+ "i18next-json": "i18next-json";
406
+ "vue-i18n-json": "vue-i18n-json";
407
+ "next-intl-json": "next-intl-json";
408
+ "ngx-translate-json": "ngx-translate-json";
409
+ xliff: "xliff";
410
+ yaml: "yaml";
411
+ arb: "arb";
412
+ }>;
413
+ /** One of the supported source formats; a member of {@link SUPPORTED_FORMATS}. */
414
+ type SupportedFormat = z.infer<typeof supportedFormatSchema>;
415
+
416
+ /**
417
+ * A single, format-neutral translation unit. Placeholders are supplied already
418
+ * extracted; core never derives them from the value.
419
+ */
420
+ declare const translationEntrySchema: z.ZodObject<{
421
+ key: z.ZodString;
422
+ namespace: z.ZodString;
423
+ value: z.ZodString;
424
+ description: z.ZodOptional<z.ZodString>;
425
+ meaning: z.ZodOptional<z.ZodString>;
426
+ placeholders: z.ZodReadonly<z.ZodArray<z.ZodString>>;
427
+ isPlural: z.ZodBoolean;
428
+ }, z.core.$strip>;
429
+ /** The validated shape of one translation unit; the inferred type of {@link translationEntrySchema}. */
430
+ type TranslationEntry = Readonly<z.infer<typeof translationEntrySchema>>;
431
+ /** All translation entries for one locale and namespace, keyed by entry key. */
432
+ interface LocaleResource {
433
+ /** The locale these entries belong to (for example, "en" or "de"). */
434
+ readonly locale: string;
435
+ /** The namespace these entries belong to. */
436
+ readonly namespace: string;
437
+ /** The source format the resource came from, for round-trip fidelity. */
438
+ readonly format: SupportedFormat;
439
+ /** Entries addressable by key. */
440
+ readonly entries: ReadonlyMap<string, TranslationEntry>;
441
+ }
442
+
443
+ /**
444
+ * The result of reading a file into core's intermediate representation.
445
+ */
446
+ interface ReadResult {
447
+ /** The parsed locale resource. */
448
+ readonly resource: LocaleResource;
449
+ /** Keys whose values are invalid for the format's message syntax. Empty for non-ICU formats. */
450
+ readonly invalidIcuKeys: readonly string[];
451
+ }
452
+ /**
453
+ * The contract every format adapter implements. A new format attaches by implementing this interface
454
+ * and registering it in an {@link AdapterRegistry}.
455
+ *
456
+ * For a JSON-family format, implement via {@link createJsonFileAdapter} rather than from scratch. A
457
+ * non-JSON format implements this interface directly and first needs its format added to core's
458
+ * `SupportedFormat`.
459
+ *
460
+ * @example
461
+ * ```ts
462
+ * const registry = createDefaultRegistry();
463
+ * const resolution = registry.resolve("locales/en.json", { format: "i18next-json" });
464
+ * if (resolution.status === "resolved") {
465
+ * const { resource } = await resolution.adapter.read("locales/en.json", "en");
466
+ * await resolution.adapter.write(resource, "locales/en.json");
467
+ * }
468
+ * ```
469
+ */
470
+ interface FormatAdapter {
471
+ /** The single format this adapter handles (a `SupportedFormat` from core). */
472
+ readonly format: SupportedFormat;
473
+ /**
474
+ * Detect whether this adapter can handle a file, by path extension and an optional content sample.
475
+ * Best-effort and side-effect-free; it reads nothing from disk.
476
+ *
477
+ * @param filePath - The path of the file under consideration.
478
+ * @param sample - An optional leading content sample to aid detection.
479
+ * @returns True if this adapter could handle the file. Several adapters may return true for the same
480
+ * file (all JSON adapters claim `.json`); the caller disambiguates by explicit format.
481
+ */
482
+ canHandle(filePath: string, sample?: string): boolean;
483
+ /**
484
+ * Read a file into a {@link LocaleResource} plus its message-validity result.
485
+ *
486
+ * @param filePath - The file to read.
487
+ * @param locale - The locale to tag the resource with.
488
+ * @returns The parsed resource and the keys whose values are invalid for the format.
489
+ * @throws {@link AdapterError} when the content is malformed, oversized, or structurally invalid
490
+ * (the implementation names the specific codes). A missing or unopenable path instead rejects with
491
+ * the underlying filesystem error.
492
+ */
493
+ read(filePath: string, locale: string): Promise<ReadResult>;
494
+ /**
495
+ * Write a {@link LocaleResource} back to a file, preserving key order and structure, atomically.
496
+ *
497
+ * @param resource - The resource to serialize.
498
+ * @param filePath - The destination file.
499
+ * @throws {@link AdapterError} if the resource cannot be represented in the format; rejects with the
500
+ * underlying filesystem error on a write failure.
501
+ */
502
+ write(resource: LocaleResource, filePath: string): Promise<void>;
503
+ /**
504
+ * Extract the format's placeholder tokens from a single value, resolving nothing.
505
+ *
506
+ * @param value - The translatable string to scan.
507
+ * @returns The placeholder tokens found, in document order. Does not throw.
508
+ */
509
+ extractPlaceholders(value: string): readonly string[];
510
+ /**
511
+ * Validate a single value against the format's message syntax before it is written. Non-ICU formats
512
+ * (i18next, vue-i18n, ngx-translate) treat every value as valid and return true.
513
+ *
514
+ * @param value - The candidate translated value to validate.
515
+ * @returns True when the value is valid for the format's message syntax. Does not throw; an
516
+ * unparseable value returns false.
517
+ */
518
+ validateMessage(value: string): boolean;
519
+ }
520
+
521
+ /** Outcome of resolving an adapter for a file. Structured; never throws. */
522
+ type AdapterResolution = {
523
+ readonly status: "resolved";
524
+ readonly adapter: FormatAdapter;
525
+ } | {
526
+ readonly status: "no-match";
527
+ readonly filePath: string;
528
+ readonly triedFormats: readonly SupportedFormat[];
529
+ } | {
530
+ readonly status: "ambiguous";
531
+ readonly filePath: string;
532
+ readonly candidates: readonly SupportedFormat[];
533
+ };
534
+ /** Options for {@link AdapterRegistry.resolve}: select a format explicitly, or aid detection. */
535
+ interface ResolveOptions {
536
+ /** A content sample to aid detection. */
537
+ readonly sample?: string;
538
+ /** Bypass detection and select this format explicitly. */
539
+ readonly format?: SupportedFormat;
540
+ }
541
+ /**
542
+ * Holds the registered adapters and resolves one for a file. Open for extension:
543
+ * adapters attach through register without changing resolution logic.
544
+ */
545
+ declare class AdapterRegistry {
546
+ private readonly adapters;
547
+ /**
548
+ * Register an adapter.
549
+ *
550
+ * @param adapter - The adapter to add.
551
+ * @returns This registry, for chaining.
552
+ */
553
+ register(adapter: FormatAdapter): this;
554
+ private formats;
555
+ private resolveByFormat;
556
+ private resolveByDetection;
557
+ /**
558
+ * Resolve the adapter for a file, by explicit format when given, otherwise by detection.
559
+ *
560
+ * @param filePath - The file to resolve an adapter for.
561
+ * @param options - `format` selects explicitly and skips detection; `sample` aids detection.
562
+ * @returns A structured {@link AdapterResolution}: `resolved`, `no-match`, or `ambiguous`. Never
563
+ * throws; an unresolvable file is a status, not an exception.
564
+ */
565
+ resolve(filePath: string, options?: ResolveOptions): AdapterResolution;
566
+ }
567
+
568
+ /** Outcome of a bounded read: the content, or why it could not be read in bounds. */
569
+ type BoundedFileRead = {
570
+ readonly kind: "ok";
571
+ readonly content: string;
572
+ } | {
573
+ readonly kind: "missing";
574
+ } | {
575
+ readonly kind: "too-large";
576
+ };
577
+ /** Outcome of a bounded binary read: the bytes, or why they could not be read in bounds. */
578
+ type BoundedBytesRead = {
579
+ readonly kind: "ok";
580
+ readonly bytes: Uint8Array;
581
+ } | {
582
+ readonly kind: "missing";
583
+ } | {
584
+ readonly kind: "too-large";
585
+ };
586
+ /**
587
+ * The minimal file-system surface the SDK needs. Reads are bounded and writes are atomic. Injectable so
588
+ * tests stay deterministic; the format adapters do their own file IO and bypass this seam.
589
+ */
590
+ interface SdkFs {
591
+ /** Whether a readable file exists at the path. */
592
+ fileExists(path: string): Promise<boolean>;
593
+ /**
594
+ * Read a file as UTF-8 through a single handle, bounded to maxBytes. TOCTOU-safe: the handle is
595
+ * fstat'd and the read never advances past the sized length, so swapping in a larger file cannot
596
+ * bypass the cap. A missing or unreadable path is "missing"; a file over the cap is "too-large".
597
+ */
598
+ readFileBounded(path: string, maxBytes: number): Promise<BoundedFileRead>;
599
+ /** Read a file as raw bytes with the same TOCTOU-safe, bounded discipline as {@link readFileBounded}. */
600
+ readBytesBounded(path: string, maxBytes: number): Promise<BoundedBytesRead>;
601
+ /** Write atomically: a temp file in the same directory, then rename over the target. */
602
+ writeFile(path: string, data: string): Promise<void>;
603
+ /** Write raw bytes atomically (temp file, then rename over the target). Used for the workbook. */
604
+ writeBytes(path: string, data: Uint8Array): Promise<void>;
605
+ }
606
+
607
+ /** Per-locale drift status: counts only, no key lists. */
608
+ interface LocaleCheckSummary {
609
+ /** The target locale this entry reports on. */
610
+ readonly locale: string;
611
+ /** Keys present in source but absent from the target. */
612
+ readonly missing: number;
613
+ /** Keys whose source changed since the target was last translated. */
614
+ readonly stale: number;
615
+ /** Keys whose recorded baseline still matches the source. */
616
+ readonly upToDate: number;
617
+ /** True when nothing needs (re)translating for this locale. */
618
+ readonly inSync: boolean;
619
+ }
620
+ /** The aggregate read-only status across all checked target locales. */
621
+ interface CheckSummary {
622
+ /** True only when every checked locale is in sync. */
623
+ readonly inSync: boolean;
624
+ /** One entry per checked target locale, in config order. */
625
+ readonly locales: readonly LocaleCheckSummary[];
626
+ }
627
+ /** Input for {@link check}: the validated config and which locales to check. */
628
+ interface CheckInput {
629
+ /** The validated configuration (typically from {@link loadConfig}). */
630
+ readonly config: VerbatraConfig;
631
+ /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
632
+ readonly cwd?: string;
633
+ /** Subset of target locales to check; defaults to all configured target locales. */
634
+ readonly locales?: readonly string[];
635
+ }
636
+ /** Composition seam for {@link check}: inject a registry and a file system for tests. */
637
+ interface CheckDeps {
638
+ readonly adapterRegistry?: AdapterRegistry;
639
+ readonly fs?: SdkFs;
640
+ }
641
+ /**
642
+ * Report which keys are missing or stale per target locale, without calling a provider, writing any
643
+ * file, or touching the lock. Each locale is reported as counts only (missing, stale, up-to-date);
644
+ * a locale is `inSync` when nothing is missing or stale, and the top-level `inSync` is true only when
645
+ * every checked locale is. Orphaned keys and integrity are not reported, since they concern a write.
646
+ *
647
+ * @param input - The validated config and which locales to check.
648
+ * @param deps - Optional composition seams (registry, file system) for tests.
649
+ * @returns The aggregate and per-locale drift status.
650
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
651
+ * with the same meanings as in `translate`.
652
+ */
653
+ declare function check(input: CheckInput, deps?: CheckDeps): Promise<CheckSummary>;
654
+
655
+ /** Per-locale pending change: the key lists, not counts. */
656
+ interface LocaleDiff {
657
+ /** The target locale this entry reports on. */
658
+ readonly locale: string;
659
+ /** Keys present in source but absent from the target; would be added by a run. */
660
+ readonly missing: readonly string[];
661
+ /** Keys whose source changed since last translated; would be re-translated. */
662
+ readonly changed: readonly string[];
663
+ /** Keys present in target but absent from source; report-only, never pending. */
664
+ readonly orphaned: readonly string[];
665
+ /** True when the locale has missing or changed keys; orphaned keys do not count. */
666
+ readonly hasPendingChanges: boolean;
667
+ }
668
+ /** The aggregate read-only diff across all checked target locales. */
669
+ interface DiffSummary {
670
+ /** True when any checked locale has pending changes. */
671
+ readonly hasPendingChanges: boolean;
672
+ /** One entry per checked target locale, in config order. */
673
+ readonly locales: readonly LocaleDiff[];
674
+ }
675
+ /** Input for {@link diff}: the validated config and which locales to diff. */
676
+ interface DiffInput {
677
+ /** The validated configuration (typically from {@link loadConfig}). */
678
+ readonly config: VerbatraConfig;
679
+ /** Directory the file pattern and lock-file resolve against; defaults to cwd. */
680
+ readonly cwd?: string;
681
+ /** Subset of target locales to diff; defaults to all configured target locales. */
682
+ readonly locales?: readonly string[];
683
+ }
684
+ /** Composition seam for {@link diff}: inject a registry and a file system for tests. */
685
+ interface DiffDeps {
686
+ readonly adapterRegistry?: AdapterRegistry;
687
+ readonly fs?: SdkFs;
688
+ }
689
+ /**
690
+ * Report the exact pending change per target locale as three key lists (missing, changed, orphaned),
691
+ * without calling a provider, writing any file, or touching the lock. This is the detailed sibling of
692
+ * {@link check}. A locale's `hasPendingChanges` is driven by missing or changed only; orphaned keys are
693
+ * reported but do not flip it, since a default `translate` run does not prune. The top-level
694
+ * `hasPendingChanges` is true when any checked locale has pending changes.
695
+ *
696
+ * @param input - The validated config and which locales to diff.
697
+ * @param deps - Optional composition seams (registry, file system) for tests.
698
+ * @returns The aggregate and per-locale pending change.
699
+ * @throws {@link SdkError} `UNKNOWN_FORMAT`, `SOURCE_UNREADABLE`, `SOURCE_INVALID`, `LOCK_FILE_INVALID`
700
+ * with the same meanings as in `translate`.
701
+ */
702
+ declare function diff(input: DiffInput, deps?: DiffDeps): Promise<DiffSummary>;
703
+
262
704
  /** A stable code for an SDK-originated notice (not a provider notice). */
263
705
  type SdkNoticeCode = "PLURAL_CATEGORIES_INCOMPLETE" | "SUB_BATCH_FAILED";
264
706
  /**
265
707
  * A notice raised by the SDK itself (not a provider), structurally identical to a {@link ProviderNotice}
266
708
  * so both share the {@link LocaleSummary.notices} channel. Carries only a stable code and a static,
267
- * secret-free message; never a key value or translatable content. The SDK keeps its own codes here so
268
- * the ai-providers `ProviderNoticeCode` union stays free of SDK concerns (the dependency arrow points
269
- * sdk -> ai-providers, never the reverse).
709
+ * secret-free message; never a key value or translatable content.
270
710
  */
271
711
  interface SdkNotice {
272
712
  /** The stable {@link SdkNoticeCode} for what the SDK is reporting. */
@@ -302,10 +742,9 @@ interface LocaleSummary {
302
742
  /** Translated keys that failed the placeholder-integrity check and were withheld. */
303
743
  readonly integrityMismatches: readonly string[];
304
744
  /**
305
- * Plural-category keys verbatra SYNTHESIZED this run (for example a Polish `items_few` the source
306
- * never supplied), kept distinct from {@link translated} because they were generated from the meaning
307
- * of the source plural forms, not translated 1:1 from a source string. Empty unless plural generation
308
- * was enabled and acted. In a dry-run this is empty (the provider is not called).
745
+ * Plural-category keys verbatra synthesized this run (for example a Polish `items_few` the source
746
+ * never supplied), kept distinct from {@link translated}. Empty unless plural generation was enabled
747
+ * and acted, and empty in a dry-run.
309
748
  */
310
749
  readonly generated: readonly string[];
311
750
  /**
@@ -336,54 +775,6 @@ interface RunSummary {
336
775
  readonly failed: readonly string[];
337
776
  }
338
777
 
339
- /** Outcome of a bounded read: the content, or why it could not be read in bounds. */
340
- type BoundedFileRead = {
341
- readonly kind: "ok";
342
- readonly content: string;
343
- } | {
344
- readonly kind: "missing";
345
- } | {
346
- readonly kind: "too-large";
347
- };
348
- /** Outcome of a bounded binary read: the bytes, or why they could not be read in bounds. */
349
- type BoundedBytesRead = {
350
- readonly kind: "ok";
351
- readonly bytes: Uint8Array;
352
- } | {
353
- readonly kind: "missing";
354
- } | {
355
- readonly kind: "too-large";
356
- };
357
- /**
358
- * The minimal file-system surface the SDK needs: existence checks, the lock-file read/write,
359
- * and the untrusted workbook bytes read/write for the export/import flow. Reads are bounded
360
- * (see {@link readFileBounded} / {@link readBytesBounded}) and writes are atomic. Injectable so
361
- * tests stay deterministic; the format adapters do their own file IO and are not routed through
362
- * this seam.
363
- */
364
- interface SdkFs {
365
- /** Whether a readable file exists at the path. */
366
- fileExists(path: string): Promise<boolean>;
367
- /**
368
- * Read a file as UTF-8 through a single handle, bounded to maxBytes. TOCTOU-safe: the
369
- * handle is fstat'd and the read never advances past the sized length, so swapping the
370
- * path for a larger file after the size check cannot bypass the cap. A missing or
371
- * unreadable path is "missing" (first-run); a file over the cap is "too-large".
372
- */
373
- readFileBounded(path: string, maxBytes: number): Promise<BoundedFileRead>;
374
- /**
375
- * Read a file as raw bytes through a single handle, bounded to maxBytes, with the SAME
376
- * TOCTOU-safe discipline as {@link readFileBounded}: the handle is fstat'd and the read never
377
- * advances past the sized length. Used for the untrusted workbook on import; a file over the
378
- * cap is "too-large", a missing path is "missing".
379
- */
380
- readBytesBounded(path: string, maxBytes: number): Promise<BoundedBytesRead>;
381
- /** Write atomically: a temp file in the same directory, then rename over the target. */
382
- writeFile(path: string, data: string): Promise<void>;
383
- /** Write raw bytes atomically (temp file, then rename over the target). Used for the workbook. */
384
- writeBytes(path: string, data: Uint8Array): Promise<void>;
385
- }
386
-
387
778
  /** Builds the provider from its config. Injectable so tests stay offline. */
388
779
  type CreateProvider = (config: ProviderConfig) => TranslationProvider;
389
780
 
@@ -497,11 +888,10 @@ interface ExportWorkbookResult {
497
888
  }[];
498
889
  }
499
890
  /**
500
- * Export the strings needing human translation into a styled `.xlsx` workbook. Reuses the same
501
- * source read, adapter selection, and lock baseline the translate flow uses, runs `diffResources`
502
- * per target locale to pick the rows (missing and changed by default; add unchanged with
503
- * `includeUnchanged`), hands the neutral row model to `@verbatra/exchange`'s `buildWorkbook`, and
504
- * writes the bytes through the {@link SdkFs} seam. No provider is called and no lock-file is written.
891
+ * Export the strings needing human translation into a styled `.xlsx` workbook. Each target locale is
892
+ * diffed against the source and lock baseline to pick the rows (missing and changed by default; add
893
+ * unchanged with `includeUnchanged`), and the bytes are written to `out`. No provider is called and no
894
+ * lock-file is written.
505
895
  *
506
896
  * @param input - The validated config and export options.
507
897
  * @param deps - Optional composition seams (registry, file system) for tests.
@@ -528,19 +918,16 @@ interface ImportWorkbookDeps {
528
918
  readonly fs?: SdkFs;
529
919
  }
530
920
  /**
531
- * Import a filled workbook back into the locale files. Reads the untrusted workbook through the
532
- * SDK's bounded read, parses it with `@verbatra/exchange`'s `readWorkbook` (which bounds and
533
- * sanitizes it), then for each target-locale data sheet runs the EXISTING core checks (source-drift
534
- * via `contentHash`, placeholder integrity via `checkPlaceholders`, ICU via the adapter's
535
- * `validateMessage`), writes the accepted values through the format adapter, and updates the lock
536
- * through the existing lock logic. Returns a {@link RunSummary} structurally identical to
537
- * `translate`'s, so the CLI formatter and exit-code rule are shared with no special case.
921
+ * Import a filled workbook back into the locale files. Each target-locale data sheet runs the same
922
+ * source-drift, placeholder, and ICU checks as the translate flow, the accepted values are written
923
+ * through the format adapter, and the lock is updated. Returns a {@link RunSummary} structurally
924
+ * identical to `translate`'s.
538
925
  *
539
926
  * Whole-run failures (unknown format, unreadable/invalid/oversized workbook, corrupt lock) throw a
540
927
  * structured {@link SdkError}. A per-sheet failure (a locale not in config, a broken-round-trip key,
541
928
  * a write failure) is isolated as that locale's `status: "failed"`, not a throw; per-row rejections
542
- * are withheld and reported on the locale, exactly as the provider path treats integrity mismatches.
543
- * `--dry-run` validates and reports without writing any locale or lock file.
929
+ * are withheld and reported on the locale. Dry-run validates and reports without writing any locale or
930
+ * lock file.
544
931
  *
545
932
  * @param input - The validated config, the workbook path, and run options.
546
933
  * @param deps - Optional composition seams (registry, file system) for tests.
@@ -549,6 +936,28 @@ interface ImportWorkbookDeps {
549
936
  */
550
937
  declare function importWorkbook(input: ImportWorkbookInput, deps?: ImportWorkbookDeps): Promise<RunSummary>;
551
938
 
939
+ /**
940
+ * Read-only metadata the CLI `init` scaffold derives its tables from, so the CLI never restates the
941
+ * provider, env-var, model, or format truth owned by core and ai-providers.
942
+ */
943
+ declare const scaffoldingMetadata: {
944
+ /** Provider id -> the environment variable its API key is read from. Owned by ai-providers. */
945
+ readonly providerEnv: {
946
+ readonly anthropic: "ANTHROPIC_API_KEY";
947
+ readonly openai: "OPENAI_API_KEY";
948
+ readonly gemini: "GEMINI_API_KEY";
949
+ readonly deepl: "DEEPL_API_KEY";
950
+ };
951
+ /** LLM provider id -> a cosmetic default scaffold model. Owned by ai-providers. DeepL has none. */
952
+ readonly scaffoldModels: {
953
+ readonly anthropic: "claude-sonnet-4-6";
954
+ readonly openai: "gpt-5.4-mini";
955
+ readonly gemini: "gemini-2.5-flash";
956
+ };
957
+ /** The closed set of source format ids. Owned by core. */
958
+ readonly supportedFormats: readonly ["i18next-json", "vue-i18n-json", "next-intl-json", "ngx-translate-json", "xliff", "yaml", "arb"];
959
+ };
960
+
552
961
  /** A minimal source-change event source. Production wraps chokidar; tests inject a stub. */
553
962
  interface Watcher {
554
963
  /** Register a listener invoked once per coalesced source-change event. */
@@ -605,16 +1014,11 @@ interface WatchController {
605
1014
  stop(): Promise<void>;
606
1015
  }
607
1016
  /**
608
- * Start watching the source file and re-run the one-shot translate on each debounced change.
609
- * Watch adds no translation/diff/lock logic: it watches, debounces, serializes, and invokes the
610
- * existing translate() unchanged. The state machine is IDLE <-> RUNNING with a single boolean
611
- * pending-rerun flag (mid-run changes collapse into one immediate follow-up; never two runs at
612
- * once). A missing source at startup is a hard error; a run that fails after start is reported and
613
- * watching continues. Returns a controller whose stop() closes the watcher and awaits the in-flight
614
- * run (the caller wires any signal, e.g. SIGINT, to it).
615
- *
616
- * Only the startup source check throws; every run outcome after start is surfaced through `onRun` as a
617
- * {@link WatchRunResult} (a failed run never rejects the in-flight promise).
1017
+ * Start watching the source file and re-run the one-shot {@link translate} on each debounced change.
1018
+ * Runs are serialized: changes during a run collapse into a single follow-up, so two runs never
1019
+ * overlap. A missing source at startup throws; every run outcome after start is surfaced through
1020
+ * `onRun` as a {@link WatchRunResult} and watching continues. Returns a controller whose `stop()`
1021
+ * closes the watcher and awaits the in-flight run.
618
1022
  *
619
1023
  * @param input - The config, optional cwd/debounce, and the `onRun` callback that receives each result.
620
1024
  * @param deps - Optional composition seams (watcher, run, registry, provider builder, file system) for tests.
@@ -646,4 +1050,4 @@ interface WatchController {
646
1050
  */
647
1051
  declare function watch(input: WatchInput, deps?: WatchDeps): Promise<WatchController>;
648
1052
 
649
- export { type CreateProvider, type CreateWatcher, DEFAULT_WORKBOOK_PATH, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type ImportWorkbookDeps, type ImportWorkbookInput, type LoadConfigOptions, type LocaleNotice, type LocaleSummary, type ProviderConfig, type ProviderId, type RunSummary, type RunTranslate, SdkError, type SdkErrorCode, type SdkFs, type SdkNotice, type SdkNoticeCode, type TranslateDeps, type TranslateInput, type VerbatraConfig, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, defineConfig, exportWorkbook, importWorkbook, loadConfig, translate, verbatraConfigSchema, watch };
1053
+ export { type CheckDeps, type CheckInput, type CheckSummary, type CreateProvider, type CreateWatcher, DEFAULT_WORKBOOK_PATH, type DiffDeps, type DiffInput, type DiffSummary, type ExportWorkbookDeps, type ExportWorkbookInput, type ExportWorkbookResult, type ImportWorkbookDeps, type ImportWorkbookInput, type LoadConfigOptions, type LocaleCheckSummary, type LocaleDiff, type LocaleNotice, type LocaleSummary, type ProviderConfig, type ProviderId, type RunSummary, type RunTranslate, SdkError, type SdkErrorCode, type SdkFs, type SdkNotice, type SdkNoticeCode, type SupportedFormat$1 as SupportedFormat, type TranslateDeps, type TranslateInput, type VerbatraConfig, type WatchController, type WatchDeps, type WatchInput, type WatchRunResult, type Watcher, check, defineConfig, diff, exportWorkbook, importWorkbook, loadConfig, scaffoldingMetadata, translate, verbatraConfigSchema, watch };