dialekt 0.1.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.
Files changed (73) hide show
  1. package/README.md +62 -0
  2. package/TESTING.md +66 -0
  3. package/dist/cli/main.d.mts +1 -0
  4. package/dist/cli/main.mjs +412 -0
  5. package/dist/formatters-De4Q-X1d.mjs +577 -0
  6. package/dist/index.d.mts +329 -0
  7. package/dist/index.mjs +60 -0
  8. package/package.json +39 -0
  9. package/pnpm-workspace.yaml +7 -0
  10. package/src/adapter/types.test.ts +98 -0
  11. package/src/adapter/types.ts +73 -0
  12. package/src/benchmark/metrics.test.ts +180 -0
  13. package/src/benchmark/metrics.ts +69 -0
  14. package/src/benchmark/report.test.ts +129 -0
  15. package/src/benchmark/report.ts +21 -0
  16. package/src/benchmark/runner.test.ts +162 -0
  17. package/src/benchmark/runner.ts +27 -0
  18. package/src/cli/commands/add.test.ts +267 -0
  19. package/src/cli/commands/add.ts +123 -0
  20. package/src/cli/commands/benchmark.test.ts +346 -0
  21. package/src/cli/commands/benchmark.ts +148 -0
  22. package/src/cli/commands/languages.test.ts +127 -0
  23. package/src/cli/commands/languages.ts +42 -0
  24. package/src/cli/commands/missing.test.ts +256 -0
  25. package/src/cli/commands/missing.ts +88 -0
  26. package/src/cli/commands/translate.test.ts +384 -0
  27. package/src/cli/commands/translate.ts +106 -0
  28. package/src/cli/commands/unused.test.ts +192 -0
  29. package/src/cli/commands/unused.ts +87 -0
  30. package/src/cli/commands/validate.test.ts +245 -0
  31. package/src/cli/commands/validate.ts +96 -0
  32. package/src/cli/config-resolution.test.ts +99 -0
  33. package/src/cli/config-resolution.ts +29 -0
  34. package/src/cli/format.test.ts +117 -0
  35. package/src/cli/format.ts +205 -0
  36. package/src/cli/formatters.test.ts +186 -0
  37. package/src/cli/formatters.ts +350 -0
  38. package/src/cli/main.ts +31 -0
  39. package/src/config/define-config.test.ts +66 -0
  40. package/src/config/define-config.ts +5 -0
  41. package/src/config/load-config.test.ts +35 -0
  42. package/src/config/load-config.ts +21 -0
  43. package/src/config/types.test.ts +101 -0
  44. package/src/config/types.ts +28 -0
  45. package/src/index.ts +56 -0
  46. package/src/keys/flatten.test.ts +111 -0
  47. package/src/keys/flatten.ts +41 -0
  48. package/src/sdk/file-io.test.ts +139 -0
  49. package/src/sdk/file-io.ts +21 -0
  50. package/src/sdk/node-layer.test.ts +54 -0
  51. package/src/sdk/node-layer.ts +10 -0
  52. package/src/sdk/php-array-reader.test.ts +114 -0
  53. package/src/sdk/php-array-reader.ts +26 -0
  54. package/src/translation/chunking.test.ts +118 -0
  55. package/src/translation/chunking.ts +57 -0
  56. package/src/translation/missing-keys.test.ts +179 -0
  57. package/src/translation/missing-keys.ts +36 -0
  58. package/src/translation/model-registry.test.ts +54 -0
  59. package/src/translation/model-registry.ts +43 -0
  60. package/src/translation/one-shot-strategy.test.ts +259 -0
  61. package/src/translation/one-shot-strategy.ts +48 -0
  62. package/src/translation/orchestrator.test.ts +276 -0
  63. package/src/translation/orchestrator.ts +83 -0
  64. package/src/translation/prompt.test.ts +149 -0
  65. package/src/translation/prompt.ts +42 -0
  66. package/src/translation/tool-loop-strategy.test.ts +279 -0
  67. package/src/translation/tool-loop-strategy.ts +68 -0
  68. package/src/translation/types.test.ts +37 -0
  69. package/src/translation/types.ts +21 -0
  70. package/tsconfig.json +9 -0
  71. package/tsconfig.tsbuildinfo +1 -0
  72. package/tsdown.config.ts +7 -0
  73. package/vitest.config.ts +8 -0
@@ -0,0 +1,346 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Effect, Option } from 'effect';
3
+ import { runBenchmarkCommand, benchmarkCommand } from './benchmark.js';
4
+ import type { DialektConfig } from '../../config/types.js';
5
+ import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
6
+ import type { TranslationStrategy, TranslationContext } from '../../translation/types.js';
7
+ import type { StrategyBenchmarkSummary } from '../../benchmark/metrics.js';
8
+
9
+ describe('runBenchmarkCommand', () => {
10
+ const baseConfig: DialektConfig = {
11
+ sourceLocale: 'en',
12
+ targetLocales: ['de'],
13
+ strategy: 'one-shot',
14
+ model: { provider: 'openai', modelId: 'gpt-4o' },
15
+ fastModel: { provider: 'openai', modelId: 'gpt-4o-mini' },
16
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
17
+ retry: { maxAttempts: 3, baseDelayMs: 1000 },
18
+ adapters: [],
19
+ };
20
+
21
+ function makeAdapter(opts: {
22
+ name: string;
23
+ locales?: readonly string[];
24
+ resources?: readonly ResourceRef[];
25
+ sourceMap?: Record<string, string>;
26
+ targetMap?: Record<string, string>;
27
+ missing?: readonly string[];
28
+ }): TranslationAdapter {
29
+ return {
30
+ name: opts.name,
31
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
32
+ listLocales: () => Effect.succeed(opts.locales ?? ['en', 'de']),
33
+ listResources: () => Effect.succeed(opts.resources ?? [{ key: 'messages', label: 'messages' }]),
34
+ readResource: (_locale: string) =>
35
+ Effect.succeed(_locale === 'en' ? (opts.sourceMap ?? {}) : (opts.targetMap ?? {})),
36
+ writeResource: () => Effect.void,
37
+ };
38
+ }
39
+
40
+ function makeDeps(
41
+ overrides?: Partial<Parameters<typeof runBenchmarkCommand>[1]>,
42
+ ): Parameters<typeof runBenchmarkCommand>[1] {
43
+ return {
44
+ configLoader: () => Effect.succeed(baseConfig),
45
+ modelResolver: () => Effect.succeed({} as unknown),
46
+ missingKeysComputer: () =>
47
+ Effect.succeed([{ adapter: 'test', locale: 'de', resource: { key: 'messages', label: 'messages' } as ResourceRef, missing: ['hello'] as readonly string[] }]),
48
+ benchmarkRunner: () =>
49
+ Effect.succeed([
50
+ {
51
+ strategyName: 'one-shot',
52
+ totalChunks: 1,
53
+ succeededChunks: 1,
54
+ failedChunks: 0,
55
+ totalDurationMs: 100,
56
+ averageDurationMsPerChunk: 100,
57
+ totalAttempts: 1,
58
+ } as StrategyBenchmarkSummary,
59
+ ]),
60
+ logger: () => Effect.void,
61
+ errorLogger: () => Effect.void,
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ it('logs a cost warning', async () => {
67
+ const errors: string[] = [];
68
+ const deps = makeDeps({
69
+ errorLogger: (msg) => Effect.sync(() => errors.push(msg)),
70
+ });
71
+
72
+ const program = runBenchmarkCommand(
73
+ {
74
+ config: './config.ts',
75
+ adapter: Option.none(),
76
+ strategies: Option.none(),
77
+ sampleSize: Option.none(),
78
+ },
79
+ deps,
80
+ );
81
+
82
+ await Effect.runPromise(program);
83
+ expect(errors.some((e) => e.includes('Warning') && e.includes('cost'))).toBe(true);
84
+ });
85
+
86
+ it('uses default strategies when none specified', async () => {
87
+ let usedStrategies: readonly string[] | undefined;
88
+ const deps = makeDeps({
89
+ benchmarkRunner: (opts) =>
90
+ Effect.sync(() => {
91
+ usedStrategies = opts.strategies.map((s) => s.name);
92
+ return [];
93
+ }),
94
+ });
95
+
96
+ const program = runBenchmarkCommand(
97
+ {
98
+ config: './config.ts',
99
+ adapter: Option.none(),
100
+ strategies: Option.none(),
101
+ sampleSize: Option.none(),
102
+ },
103
+ deps,
104
+ );
105
+
106
+ await Effect.runPromise(program);
107
+ expect(usedStrategies).toEqual(['one-shot', 'tool-loop-agent']);
108
+ });
109
+
110
+ it('uses custom strategies when --strategies is passed', async () => {
111
+ let usedStrategies: readonly string[] | undefined;
112
+ const deps = makeDeps({
113
+ benchmarkRunner: (opts) =>
114
+ Effect.sync(() => {
115
+ usedStrategies = opts.strategies.map((s) => s.name);
116
+ return [];
117
+ }),
118
+ });
119
+
120
+ const program = runBenchmarkCommand(
121
+ {
122
+ config: './config.ts',
123
+ adapter: Option.none(),
124
+ strategies: Option.some('tool-loop-agent'),
125
+ sampleSize: Option.none(),
126
+ },
127
+ deps,
128
+ );
129
+
130
+ await Effect.runPromise(program);
131
+ expect(usedStrategies).toEqual(['tool-loop-agent']);
132
+ });
133
+
134
+ it('limits sample size with --sample-size', async () => {
135
+ let usedChunks: readonly TranslationContext[] | undefined;
136
+ const adapter = makeAdapter({
137
+ name: 'test',
138
+ sourceMap: { a: 'A', b: 'B', c: 'C' },
139
+ targetMap: {},
140
+ });
141
+ const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
142
+ const deps = makeDeps({
143
+ configLoader: () => Effect.succeed(config),
144
+ benchmarkRunner: (opts) =>
145
+ Effect.sync(() => {
146
+ usedChunks = opts.chunks;
147
+ return [];
148
+ }),
149
+ missingKeysComputer: () =>
150
+ Effect.succeed([
151
+ { adapter: 'test', locale: 'de', resource: { key: 'messages', label: 'messages' } as ResourceRef, missing: ['a', 'b', 'c'] as readonly string[] },
152
+ ]),
153
+ });
154
+
155
+ const program = runBenchmarkCommand(
156
+ {
157
+ config: './config.ts',
158
+ adapter: Option.none(),
159
+ strategies: Option.none(),
160
+ sampleSize: Option.some(1),
161
+ },
162
+ deps,
163
+ );
164
+
165
+ await Effect.runPromise(program);
166
+ expect(usedChunks).toHaveLength(1);
167
+ });
168
+
169
+ it('outputs JSON report when --format json is passed', async () => {
170
+ const logs: string[] = [];
171
+ const deps = makeDeps({
172
+ logger: (msg) => Effect.sync(() => logs.push(msg)),
173
+ });
174
+
175
+ const program = runBenchmarkCommand(
176
+ {
177
+ config: './config.ts',
178
+ adapter: Option.none(),
179
+ strategies: Option.none(),
180
+ sampleSize: Option.none(),
181
+ format: Option.some('json'),
182
+ },
183
+ deps,
184
+ );
185
+
186
+ await Effect.runPromise(program);
187
+ expect(logs).toHaveLength(1);
188
+ const parsed = JSON.parse(logs[0]!);
189
+ expect(parsed).toBeInstanceOf(Array);
190
+ expect(parsed[0]).toMatchObject({ strategyName: 'one-shot' });
191
+ });
192
+
193
+ it('outputs benchmark data by default', async () => {
194
+ const logs: string[] = [];
195
+ const deps = makeDeps({
196
+ logger: (msg) => Effect.sync(() => logs.push(msg)),
197
+ });
198
+
199
+ const program = runBenchmarkCommand(
200
+ {
201
+ config: './config.ts',
202
+ adapter: Option.none(),
203
+ strategies: Option.none(),
204
+ sampleSize: Option.none(),
205
+ },
206
+ deps,
207
+ );
208
+
209
+ await Effect.runPromise(program);
210
+ expect(logs).toHaveLength(1);
211
+ const parsed = JSON.parse(logs[0]!);
212
+ expect(parsed).toBeInstanceOf(Array);
213
+ });
214
+
215
+ it('filters adapters by --adapter flag', async () => {
216
+ let queriedAdapter: string | undefined;
217
+ const a1 = makeAdapter({ name: 'a1' });
218
+ const a2 = makeAdapter({ name: 'a2' });
219
+ const config = { ...baseConfig, adapters: [a1, a2] as unknown as DialektConfig['adapters'] };
220
+ const deps = makeDeps({
221
+ configLoader: () => Effect.succeed(config),
222
+ missingKeysComputer: (a) =>
223
+ Effect.sync(() => {
224
+ queriedAdapter = a.name;
225
+ return [];
226
+ }),
227
+ });
228
+
229
+ const program = runBenchmarkCommand(
230
+ {
231
+ config: './config.ts',
232
+ adapter: Option.some('a2'),
233
+ strategies: Option.none(),
234
+ sampleSize: Option.none(),
235
+ },
236
+ deps,
237
+ );
238
+
239
+ await Effect.runPromise(program);
240
+ expect(queriedAdapter).toBe('a2');
241
+ });
242
+
243
+ it('fails when configLoader fails', async () => {
244
+ const deps = makeDeps({
245
+ configLoader: () => Effect.fail(new Error('Config not found')),
246
+ });
247
+
248
+ const program = runBenchmarkCommand(
249
+ {
250
+ config: './missing.ts',
251
+ adapter: Option.none(),
252
+ strategies: Option.none(),
253
+ sampleSize: Option.none(),
254
+ },
255
+ deps,
256
+ );
257
+
258
+ await expect(Effect.runPromise(program)).rejects.toThrow('Config not found');
259
+ });
260
+
261
+ it('fails when modelResolver fails', async () => {
262
+ const deps = makeDeps({
263
+ modelResolver: () => Effect.fail(new Error('Model unavailable')),
264
+ });
265
+
266
+ const program = runBenchmarkCommand(
267
+ {
268
+ config: './config.ts',
269
+ adapter: Option.none(),
270
+ strategies: Option.none(),
271
+ sampleSize: Option.none(),
272
+ },
273
+ deps,
274
+ );
275
+
276
+ await expect(Effect.runPromise(program)).rejects.toThrow('Model unavailable');
277
+ });
278
+
279
+ it('fails when benchmarkRunner fails', async () => {
280
+ const deps = makeDeps({
281
+ benchmarkRunner: () => Effect.fail(new Error('Benchmark crashed')),
282
+ });
283
+
284
+ const program = runBenchmarkCommand(
285
+ {
286
+ config: './config.ts',
287
+ adapter: Option.none(),
288
+ strategies: Option.none(),
289
+ sampleSize: Option.none(),
290
+ },
291
+ deps,
292
+ );
293
+
294
+ await expect(Effect.runPromise(program)).rejects.toThrow('Benchmark crashed');
295
+ });
296
+
297
+ it('handles empty adapter list', async () => {
298
+ const logs: string[] = [];
299
+ const config = { ...baseConfig, adapters: [] };
300
+ const deps = makeDeps({
301
+ configLoader: () => Effect.succeed(config),
302
+ benchmarkRunner: () => Effect.succeed([]),
303
+ logger: (msg) => Effect.sync(() => logs.push(msg)),
304
+ });
305
+
306
+ const program = runBenchmarkCommand(
307
+ {
308
+ config: './config.ts',
309
+ adapter: Option.none(),
310
+ strategies: Option.none(),
311
+ sampleSize: Option.none(),
312
+ },
313
+ deps,
314
+ );
315
+
316
+ await Effect.runPromise(program);
317
+ expect(logs).toHaveLength(1);
318
+ const parsed = JSON.parse(logs[0]!);
319
+ expect(parsed).toEqual([]);
320
+ });
321
+
322
+ it('handles adapter with no missing keys', async () => {
323
+ let usedChunks: readonly TranslationContext[] | undefined;
324
+ const deps = makeDeps({
325
+ missingKeysComputer: () => Effect.succeed([]),
326
+ benchmarkRunner: (opts) =>
327
+ Effect.sync(() => {
328
+ usedChunks = opts.chunks;
329
+ return [];
330
+ }),
331
+ });
332
+
333
+ const program = runBenchmarkCommand(
334
+ {
335
+ config: './config.ts',
336
+ adapter: Option.none(),
337
+ strategies: Option.none(),
338
+ sampleSize: Option.none(),
339
+ },
340
+ deps,
341
+ );
342
+
343
+ await Effect.runPromise(program);
344
+ expect(usedChunks).toHaveLength(0);
345
+ });
346
+ });
@@ -0,0 +1,148 @@
1
+ import { Command, Options } from '@effect/cli';
2
+ import { Effect, Console, Option } from 'effect';
3
+ import { loadConfig } from '../../config/load-config.js';
4
+ import { resolveEffectiveConfig } from '../config-resolution.js';
5
+ import { resolveModel } from '../../translation/model-registry.js';
6
+ import { createOneShotStrategy } from '../../translation/one-shot-strategy.js';
7
+ import { createToolLoopStrategy } from '../../translation/tool-loop-strategy.js';
8
+ import { chunkKeys } from '../../translation/chunking.js';
9
+ import { computeMissingKeys } from '../../translation/missing-keys.js';
10
+ import { runBenchmark } from '../../benchmark/runner.js';
11
+ import { detectFormat, type OutputFormat } from '../format.js';
12
+ import { formatBenchmark, formatError } from '../formatters.js';
13
+ import type { DialektConfig } from '../../config/types.js';
14
+ import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
15
+ import type { TranslationStrategy, TranslationContext } from '../../translation/types.js';
16
+ import type { StrategyBenchmarkSummary } from '../../benchmark/metrics.js';
17
+
18
+ export interface BenchmarkFlags {
19
+ readonly config: string;
20
+ readonly adapter: Option.Option<string>;
21
+ readonly strategies: Option.Option<string>;
22
+ readonly sampleSize: Option.Option<number>;
23
+ readonly format?: Option.Option<string>;
24
+ }
25
+
26
+ export interface BenchmarkDeps {
27
+ readonly configLoader: (path: string) => Effect.Effect<DialektConfig, unknown>;
28
+ readonly modelResolver: (config: { provider: string; modelId: string }) => Effect.Effect<unknown, unknown>;
29
+ readonly missingKeysComputer: (
30
+ adapter: TranslationAdapter,
31
+ sourceLocale: string,
32
+ targetLocales: readonly string[],
33
+ ) => Effect.Effect<readonly { adapter: string; locale: string; resource: ResourceRef; missing: readonly string[] }[], unknown>;
34
+ readonly benchmarkRunner: (opts: {
35
+ strategies: readonly TranslationStrategy[];
36
+ chunks: readonly TranslationContext[];
37
+ concurrency: number;
38
+ }) => Effect.Effect<readonly StrategyBenchmarkSummary[], unknown>;
39
+ readonly reportFormatter?: (summaries: readonly StrategyBenchmarkSummary[], format: 'table' | 'json') => string;
40
+ readonly logger: (msg: string) => Effect.Effect<void>;
41
+ readonly errorLogger: (msg: string) => Effect.Effect<void>;
42
+ }
43
+
44
+ export function runBenchmarkCommand(
45
+ flags: BenchmarkFlags,
46
+ deps: BenchmarkDeps,
47
+ ): Effect.Effect<void, unknown> {
48
+ return Effect.gen(function* () {
49
+ yield* deps.errorLogger(
50
+ formatError(
51
+ 'Warning: This will make real API calls to the configured model provider(s) and may incur cost.',
52
+ detectFormat(
53
+ flags.format !== undefined
54
+ ? (Option.getOrUndefined(flags.format) as OutputFormat | undefined)
55
+ : undefined,
56
+ ),
57
+ ),
58
+ );
59
+
60
+ const loaded = yield* deps.configLoader(flags.config);
61
+ const effective = resolveEffectiveConfig(
62
+ { adapter: Option.getOrUndefined(flags.adapter) },
63
+ loaded,
64
+ );
65
+
66
+ const strategyNames = Option.getOrElse(flags.strategies, () => 'one-shot,tool-loop-agent')
67
+ .split(',')
68
+ .map((s: string) => s.trim()) as Array<'one-shot' | 'tool-loop-agent'>;
69
+
70
+ const model = yield* deps.modelResolver(effective.model) as Effect.Effect<import('ai').LanguageModel, unknown>;
71
+
72
+ const strategyList = strategyNames.map((name) =>
73
+ name === 'tool-loop-agent'
74
+ ? createToolLoopStrategy({ model, retry: effective.retry })
75
+ : createOneShotStrategy({ model, retry: effective.retry }),
76
+ );
77
+
78
+ const allChunks: TranslationContext[] = [];
79
+
80
+ for (const a of effective.adapters) {
81
+ const locales = yield* a.listLocales();
82
+ const sourceLocale = effective.sourceLocale;
83
+ const targets = locales.filter((l) => l !== sourceLocale);
84
+ const missingEntries = yield* deps.missingKeysComputer(a, sourceLocale, targets);
85
+
86
+ for (const entry of missingEntries) {
87
+ const sourceMap = yield* a.readResource(sourceLocale, entry.resource);
88
+ const targetMap = yield* a.readResource(entry.locale, entry.resource);
89
+ const chunks = chunkKeys(entry.missing, sourceMap, targetMap, {
90
+ maxTokens: effective.chunking.maxTokens,
91
+ charsPerToken: effective.chunking.charsPerToken,
92
+ });
93
+ for (const keys of chunks) {
94
+ allChunks.push({
95
+ sourceLocale,
96
+ targetLocale: entry.locale,
97
+ sourceMap,
98
+ targetMap,
99
+ keys,
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ const sampled = allChunks.slice(0, Option.getOrElse(flags.sampleSize, () => 20));
106
+
107
+ const summaries = yield* deps.benchmarkRunner({
108
+ strategies: strategyList,
109
+ chunks: sampled,
110
+ concurrency: effective.chunking.concurrency,
111
+ });
112
+
113
+ const format = detectFormat(
114
+ flags.format !== undefined
115
+ ? (Option.getOrUndefined(flags.format) as OutputFormat | undefined)
116
+ : undefined,
117
+ );
118
+
119
+ const entries = summaries.map((s) => ({
120
+ strategyName: s.strategyName,
121
+ totalChunks: s.totalChunks,
122
+ succeededChunks: s.succeededChunks,
123
+ failedChunks: s.failedChunks,
124
+ totalDurationMs: s.totalDurationMs,
125
+ averageDurationMsPerChunk: s.averageDurationMsPerChunk,
126
+ totalAttempts: s.totalAttempts,
127
+ }));
128
+
129
+ yield* deps.logger(formatBenchmark(entries, format));
130
+ });
131
+ }
132
+
133
+ export const benchmarkCommand = Command.make('benchmark', {
134
+ config: Options.text('config').pipe(Options.withDefault('./dialekt.config.ts')),
135
+ adapter: Options.optional(Options.text('adapter')),
136
+ strategies: Options.optional(Options.text('strategies')),
137
+ sampleSize: Options.optional(Options.integer('sample-size')),
138
+ format: Options.optional(Options.text('format')),
139
+ }, (flags) =>
140
+ runBenchmarkCommand(flags, {
141
+ configLoader: loadConfig,
142
+ modelResolver: resolveModel,
143
+ missingKeysComputer: computeMissingKeys as unknown as BenchmarkDeps['missingKeysComputer'],
144
+ benchmarkRunner: runBenchmark,
145
+ logger: (msg: string) => Console.log(msg),
146
+ errorLogger: (msg: string) => Console.error(msg),
147
+ }),
148
+ );
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Effect, Option } from 'effect';
3
+ import { runLanguages, languagesCommand } from './languages.js';
4
+ import type { DialektConfig } from '../../config/types.js';
5
+ import type { TranslationAdapter } from '../../adapter/types.js';
6
+
7
+ describe('runLanguages', () => {
8
+ const baseConfig: DialektConfig = {
9
+ sourceLocale: 'en',
10
+ targetLocales: ['de', 'fr'],
11
+ strategy: 'one-shot',
12
+ model: { provider: 'openai', modelId: 'gpt-4o' },
13
+ fastModel: { provider: 'openai', modelId: 'gpt-4o-mini' },
14
+ chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
15
+ retry: { maxAttempts: 3, baseDelayMs: 1000 },
16
+ adapters: [],
17
+ };
18
+
19
+ function makeAdapter(opts: { name: string; locales?: readonly string[] }): TranslationAdapter {
20
+ return {
21
+ name: opts.name,
22
+ capabilities: { canCreateResource: true, unusedKeyDetection: false },
23
+ listLocales: () => Effect.succeed(opts.locales ?? ['en', 'de']),
24
+ listResources: () => Effect.succeed([]),
25
+ readResource: () => Effect.succeed({}),
26
+ writeResource: () => Effect.void,
27
+ };
28
+ }
29
+
30
+ it('logs locales for each adapter', async () => {
31
+ const logs: string[] = [];
32
+ const laravel = makeAdapter({ name: 'laravel', locales: ['en', 'de', 'fr'] });
33
+ const paraglide = makeAdapter({ name: 'paraglide', locales: ['en', 'es'] });
34
+ const config = { ...baseConfig, adapters: [laravel, paraglide] as unknown as DialektConfig['adapters'] };
35
+
36
+ const program = runLanguages(
37
+ { config: './config.ts' },
38
+ () => Effect.succeed(config),
39
+ (msg) => Effect.sync(() => logs.push(msg)),
40
+ );
41
+
42
+ await Effect.runPromise(program);
43
+ expect(logs).toHaveLength(1);
44
+ const parsed = JSON.parse(logs[0]!);
45
+ expect(parsed).toHaveLength(2);
46
+ expect(parsed).toContainEqual({ adapter: 'laravel', locales: ['en', 'de', 'fr'] });
47
+ expect(parsed).toContainEqual({ adapter: 'paraglide', locales: ['en', 'es'] });
48
+ });
49
+
50
+ it('handles single adapter with single locale', async () => {
51
+ const logs: string[] = [];
52
+ const adapter = makeAdapter({ name: 'mono', locales: ['en'] });
53
+ const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
54
+
55
+ const program = runLanguages(
56
+ { config: './config.ts' },
57
+ () => Effect.succeed(config),
58
+ (msg) => Effect.sync(() => logs.push(msg)),
59
+ );
60
+
61
+ await Effect.runPromise(program);
62
+ expect(logs).toHaveLength(1);
63
+ const parsed = JSON.parse(logs[0]!);
64
+ expect(parsed).toEqual([{ adapter: 'mono', locales: ['en'] }]);
65
+ });
66
+
67
+ it('handles empty adapter list', async () => {
68
+ const logs: string[] = [];
69
+ const config = { ...baseConfig, adapters: [] };
70
+
71
+ const program = runLanguages(
72
+ { config: './config.ts' },
73
+ () => Effect.succeed(config),
74
+ (msg) => Effect.sync(() => logs.push(msg)),
75
+ );
76
+
77
+ await Effect.runPromise(program);
78
+ expect(logs).toHaveLength(1);
79
+ const parsed = JSON.parse(logs[0]!);
80
+ expect(parsed).toEqual([]);
81
+ });
82
+
83
+ it('fails when configLoader fails', async () => {
84
+ const program = runLanguages(
85
+ { config: './missing.ts' },
86
+ () => Effect.fail(new Error('Config not found')),
87
+ () => Effect.void,
88
+ );
89
+
90
+ await expect(Effect.runPromise(program)).rejects.toThrow('Config not found');
91
+ });
92
+
93
+ it('handles adapter with empty locales', async () => {
94
+ const logs: string[] = [];
95
+ const adapter = makeAdapter({ name: 'empty', locales: [] });
96
+ const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
97
+
98
+ const program = runLanguages(
99
+ { config: './config.ts' },
100
+ () => Effect.succeed(config),
101
+ (msg) => Effect.sync(() => logs.push(msg)),
102
+ );
103
+
104
+ await Effect.runPromise(program);
105
+ expect(logs).toHaveLength(1);
106
+ const parsed = JSON.parse(logs[0]!);
107
+ expect(parsed).toEqual([{ adapter: 'empty', locales: [] }]);
108
+ });
109
+
110
+ it('outputs pretty when --format pretty is passed', async () => {
111
+ const logs: string[] = [];
112
+ const adapter = makeAdapter({ name: 'test', locales: ['en', 'de'] });
113
+ const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
114
+
115
+ const program = runLanguages(
116
+ { config: './config.ts', format: Option.some('pretty') },
117
+ () => Effect.succeed(config),
118
+ (msg) => Effect.sync(() => logs.push(msg)),
119
+ );
120
+
121
+ await Effect.runPromise(program);
122
+ expect(logs).toHaveLength(1);
123
+ expect(logs[0]).toContain('test');
124
+ expect(logs[0]).toContain('en');
125
+ expect(logs[0]).toContain('de');
126
+ });
127
+ });
@@ -0,0 +1,42 @@
1
+ import { Command, Options } from '@effect/cli';
2
+ import { Effect, Console, Option } from 'effect';
3
+ import { loadConfig } from '../../config/load-config.js';
4
+ import { resolveEffectiveConfig } from '../config-resolution.js';
5
+ import { detectFormat, type OutputFormat } from '../format.js';
6
+ import { formatLanguages } from '../formatters.js';
7
+ import type { DialektConfig } from '../../config/types.js';
8
+ import type { TranslationAdapter } from '../../adapter/types.js';
9
+
10
+ export interface LanguagesFlags {
11
+ readonly config: string;
12
+ readonly format?: Option.Option<string>;
13
+ }
14
+
15
+ export function runLanguages(
16
+ flags: LanguagesFlags,
17
+ configLoader: (path: string) => Effect.Effect<DialektConfig, unknown> = loadConfig,
18
+ logger: (msg: string) => Effect.Effect<void> = (msg: string) => Console.log(msg),
19
+ ): Effect.Effect<void, never> {
20
+ return Effect.gen(function* () {
21
+ const loaded = yield* configLoader(flags.config);
22
+ const effective = resolveEffectiveConfig({}, loaded);
23
+
24
+ const entries: Array<{ adapter: string; locales: readonly string[] }> = [];
25
+ for (const adapter of effective.adapters) {
26
+ const locales = yield* adapter.listLocales();
27
+ entries.push({ adapter: adapter.name, locales });
28
+ }
29
+
30
+ const format = detectFormat(
31
+ flags.format !== undefined
32
+ ? (Option.getOrUndefined(flags.format) as OutputFormat | undefined)
33
+ : undefined,
34
+ );
35
+ yield* logger(formatLanguages(entries, format));
36
+ }).pipe(Effect.mapError((e) => e as never)) as Effect.Effect<void, never, never>;
37
+ }
38
+
39
+ export const languagesCommand = Command.make('languages', {
40
+ config: Options.text('config').pipe(Options.withDefault('./dialekt.config.ts')),
41
+ format: Options.optional(Options.text('format')),
42
+ }, (flags) => runLanguages(flags));