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.
- package/README.md +62 -0
- package/TESTING.md +66 -0
- package/dist/cli/main.d.mts +1 -0
- package/dist/cli/main.mjs +412 -0
- package/dist/formatters-De4Q-X1d.mjs +577 -0
- package/dist/index.d.mts +329 -0
- package/dist/index.mjs +60 -0
- package/package.json +39 -0
- package/pnpm-workspace.yaml +7 -0
- package/src/adapter/types.test.ts +98 -0
- package/src/adapter/types.ts +73 -0
- package/src/benchmark/metrics.test.ts +180 -0
- package/src/benchmark/metrics.ts +69 -0
- package/src/benchmark/report.test.ts +129 -0
- package/src/benchmark/report.ts +21 -0
- package/src/benchmark/runner.test.ts +162 -0
- package/src/benchmark/runner.ts +27 -0
- package/src/cli/commands/add.test.ts +267 -0
- package/src/cli/commands/add.ts +123 -0
- package/src/cli/commands/benchmark.test.ts +346 -0
- package/src/cli/commands/benchmark.ts +148 -0
- package/src/cli/commands/languages.test.ts +127 -0
- package/src/cli/commands/languages.ts +42 -0
- package/src/cli/commands/missing.test.ts +256 -0
- package/src/cli/commands/missing.ts +88 -0
- package/src/cli/commands/translate.test.ts +384 -0
- package/src/cli/commands/translate.ts +106 -0
- package/src/cli/commands/unused.test.ts +192 -0
- package/src/cli/commands/unused.ts +87 -0
- package/src/cli/commands/validate.test.ts +245 -0
- package/src/cli/commands/validate.ts +96 -0
- package/src/cli/config-resolution.test.ts +99 -0
- package/src/cli/config-resolution.ts +29 -0
- package/src/cli/format.test.ts +117 -0
- package/src/cli/format.ts +205 -0
- package/src/cli/formatters.test.ts +186 -0
- package/src/cli/formatters.ts +350 -0
- package/src/cli/main.ts +31 -0
- package/src/config/define-config.test.ts +66 -0
- package/src/config/define-config.ts +5 -0
- package/src/config/load-config.test.ts +35 -0
- package/src/config/load-config.ts +21 -0
- package/src/config/types.test.ts +101 -0
- package/src/config/types.ts +28 -0
- package/src/index.ts +56 -0
- package/src/keys/flatten.test.ts +111 -0
- package/src/keys/flatten.ts +41 -0
- package/src/sdk/file-io.test.ts +139 -0
- package/src/sdk/file-io.ts +21 -0
- package/src/sdk/node-layer.test.ts +54 -0
- package/src/sdk/node-layer.ts +10 -0
- package/src/sdk/php-array-reader.test.ts +114 -0
- package/src/sdk/php-array-reader.ts +26 -0
- package/src/translation/chunking.test.ts +118 -0
- package/src/translation/chunking.ts +57 -0
- package/src/translation/missing-keys.test.ts +179 -0
- package/src/translation/missing-keys.ts +36 -0
- package/src/translation/model-registry.test.ts +54 -0
- package/src/translation/model-registry.ts +43 -0
- package/src/translation/one-shot-strategy.test.ts +259 -0
- package/src/translation/one-shot-strategy.ts +48 -0
- package/src/translation/orchestrator.test.ts +276 -0
- package/src/translation/orchestrator.ts +83 -0
- package/src/translation/prompt.test.ts +149 -0
- package/src/translation/prompt.ts +42 -0
- package/src/translation/tool-loop-strategy.test.ts +279 -0
- package/src/translation/tool-loop-strategy.ts +68 -0
- package/src/translation/types.test.ts +37 -0
- package/src/translation/types.ts +21 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.ts +7 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect, Option } from 'effect';
|
|
3
|
+
import { runTranslate, translateCommand } from './translate.js';
|
|
4
|
+
import type { DialektConfig } from '../../config/types.js';
|
|
5
|
+
import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
|
|
6
|
+
import type { TranslationStrategy } from '../../translation/types.js';
|
|
7
|
+
|
|
8
|
+
describe('runTranslate', () => {
|
|
9
|
+
const baseConfig: DialektConfig = {
|
|
10
|
+
sourceLocale: 'en',
|
|
11
|
+
targetLocales: ['de', 'fr'],
|
|
12
|
+
strategy: 'one-shot',
|
|
13
|
+
model: { provider: 'openai', modelId: 'gpt-4o' },
|
|
14
|
+
fastModel: { provider: 'openai', modelId: 'gpt-4o-mini' },
|
|
15
|
+
chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
|
|
16
|
+
retry: { maxAttempts: 3, baseDelayMs: 1000 },
|
|
17
|
+
adapters: [],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function makeAdapter(opts: { name: string; locales?: readonly string[] }): TranslationAdapter {
|
|
21
|
+
return {
|
|
22
|
+
name: opts.name,
|
|
23
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
24
|
+
listLocales: () => Effect.succeed(opts.locales ?? ['en', 'de']),
|
|
25
|
+
listResources: () => Effect.succeed([]),
|
|
26
|
+
readResource: () => Effect.succeed({}),
|
|
27
|
+
writeResource: () => Effect.void,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it('loads config and runs translation with default model', async () => {
|
|
32
|
+
const logs: string[] = [];
|
|
33
|
+
let translationCalls = 0;
|
|
34
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
35
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
36
|
+
|
|
37
|
+
const program = runTranslate(
|
|
38
|
+
{
|
|
39
|
+
config: './config.ts',
|
|
40
|
+
adapter: Option.none(),
|
|
41
|
+
strategy: Option.none(),
|
|
42
|
+
baseLanguage: Option.none(),
|
|
43
|
+
language: Option.none(),
|
|
44
|
+
name: Option.none(),
|
|
45
|
+
skipNames: false,
|
|
46
|
+
skipLanguages: false,
|
|
47
|
+
fast: false,
|
|
48
|
+
},
|
|
49
|
+
() => Effect.succeed(config),
|
|
50
|
+
() => Effect.succeed({}),
|
|
51
|
+
(opts) =>
|
|
52
|
+
Effect.sync(() => {
|
|
53
|
+
translationCalls++;
|
|
54
|
+
expect(opts.adapters).toHaveLength(1);
|
|
55
|
+
expect(opts.sourceLocale).toBe('en');
|
|
56
|
+
}),
|
|
57
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await Effect.runPromise(program);
|
|
61
|
+
expect(translationCalls).toBe(1);
|
|
62
|
+
expect(logs).toHaveLength(1);
|
|
63
|
+
const parsed = JSON.parse(logs[0]!);
|
|
64
|
+
expect(parsed.success).toBe(true);
|
|
65
|
+
expect(parsed.message).toBe('Translation complete.');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('uses fastModel when --fast is passed', async () => {
|
|
69
|
+
let usedModel: string | undefined;
|
|
70
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
71
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
72
|
+
|
|
73
|
+
const program = runTranslate(
|
|
74
|
+
{
|
|
75
|
+
config: './config.ts',
|
|
76
|
+
adapter: Option.none(),
|
|
77
|
+
strategy: Option.none(),
|
|
78
|
+
baseLanguage: Option.none(),
|
|
79
|
+
language: Option.none(),
|
|
80
|
+
name: Option.none(),
|
|
81
|
+
skipNames: false,
|
|
82
|
+
skipLanguages: false,
|
|
83
|
+
fast: true,
|
|
84
|
+
},
|
|
85
|
+
() => Effect.succeed(config),
|
|
86
|
+
(modelConfig) =>
|
|
87
|
+
Effect.sync(() => {
|
|
88
|
+
usedModel = modelConfig.modelId;
|
|
89
|
+
return {};
|
|
90
|
+
}),
|
|
91
|
+
() => Effect.void,
|
|
92
|
+
() => Effect.void,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await Effect.runPromise(program);
|
|
96
|
+
expect(usedModel).toBe('gpt-4o-mini');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('uses default strategy from config', async () => {
|
|
100
|
+
let strategy: TranslationStrategy | undefined;
|
|
101
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
102
|
+
const config: DialektConfig = {
|
|
103
|
+
...baseConfig,
|
|
104
|
+
strategy: 'tool-loop-agent',
|
|
105
|
+
adapters: [adapter] as unknown as DialektConfig['adapters'],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const program = runTranslate(
|
|
109
|
+
{
|
|
110
|
+
config: './config.ts',
|
|
111
|
+
adapter: Option.none(),
|
|
112
|
+
strategy: Option.none(),
|
|
113
|
+
baseLanguage: Option.none(),
|
|
114
|
+
language: Option.none(),
|
|
115
|
+
name: Option.none(),
|
|
116
|
+
skipNames: false,
|
|
117
|
+
skipLanguages: false,
|
|
118
|
+
fast: false,
|
|
119
|
+
},
|
|
120
|
+
() => Effect.succeed(config),
|
|
121
|
+
() => Effect.succeed({}),
|
|
122
|
+
(opts) =>
|
|
123
|
+
Effect.sync(() => {
|
|
124
|
+
strategy = opts.strategy;
|
|
125
|
+
}),
|
|
126
|
+
() => Effect.void,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
await Effect.runPromise(program);
|
|
130
|
+
expect(strategy).toBeDefined();
|
|
131
|
+
expect(strategy!.name).toBe('tool-loop-agent');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('overrides strategy with --strategy flag', async () => {
|
|
135
|
+
let strategyName: string | undefined;
|
|
136
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
137
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
138
|
+
|
|
139
|
+
const program = runTranslate(
|
|
140
|
+
{
|
|
141
|
+
config: './config.ts',
|
|
142
|
+
adapter: Option.none(),
|
|
143
|
+
strategy: Option.some('tool-loop-agent'),
|
|
144
|
+
baseLanguage: Option.none(),
|
|
145
|
+
language: Option.none(),
|
|
146
|
+
name: Option.none(),
|
|
147
|
+
skipNames: false,
|
|
148
|
+
skipLanguages: false,
|
|
149
|
+
fast: false,
|
|
150
|
+
},
|
|
151
|
+
() => Effect.succeed(config),
|
|
152
|
+
() => Effect.succeed({}),
|
|
153
|
+
(opts) =>
|
|
154
|
+
Effect.sync(() => {
|
|
155
|
+
strategyName = opts.strategy.name;
|
|
156
|
+
}),
|
|
157
|
+
() => Effect.void,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
await Effect.runPromise(program);
|
|
161
|
+
expect(strategyName).toBe('tool-loop-agent');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('filters adapters by --adapter flag', async () => {
|
|
165
|
+
let usedAdapterName: string | undefined;
|
|
166
|
+
const a1 = makeAdapter({ name: 'a1' });
|
|
167
|
+
const a2 = makeAdapter({ name: 'a2' });
|
|
168
|
+
const config = { ...baseConfig, adapters: [a1, a2] as unknown as DialektConfig['adapters'] };
|
|
169
|
+
|
|
170
|
+
const program = runTranslate(
|
|
171
|
+
{
|
|
172
|
+
config: './config.ts',
|
|
173
|
+
adapter: Option.some('a2'),
|
|
174
|
+
strategy: Option.none(),
|
|
175
|
+
baseLanguage: Option.none(),
|
|
176
|
+
language: Option.none(),
|
|
177
|
+
name: Option.none(),
|
|
178
|
+
skipNames: false,
|
|
179
|
+
skipLanguages: false,
|
|
180
|
+
fast: false,
|
|
181
|
+
},
|
|
182
|
+
() => Effect.succeed(config),
|
|
183
|
+
() => Effect.succeed({}),
|
|
184
|
+
(opts) =>
|
|
185
|
+
Effect.sync(() => {
|
|
186
|
+
usedAdapterName = (opts.adapters as TranslationAdapter[])[0]!.name;
|
|
187
|
+
}),
|
|
188
|
+
() => Effect.void,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await Effect.runPromise(program);
|
|
192
|
+
expect(usedAdapterName).toBe('a2');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('fails when configLoader fails', async () => {
|
|
196
|
+
const program = runTranslate(
|
|
197
|
+
{
|
|
198
|
+
config: './missing.ts',
|
|
199
|
+
adapter: Option.none(),
|
|
200
|
+
strategy: Option.none(),
|
|
201
|
+
baseLanguage: Option.none(),
|
|
202
|
+
language: Option.none(),
|
|
203
|
+
name: Option.none(),
|
|
204
|
+
skipNames: false,
|
|
205
|
+
skipLanguages: false,
|
|
206
|
+
fast: false,
|
|
207
|
+
},
|
|
208
|
+
() => Effect.fail(new Error('Config not found')),
|
|
209
|
+
() => Effect.succeed({}),
|
|
210
|
+
() => Effect.void,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await expect(Effect.runPromise(program)).rejects.toThrow('Config not found');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('fails when modelResolver fails', async () => {
|
|
217
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
218
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
219
|
+
|
|
220
|
+
const program = runTranslate(
|
|
221
|
+
{
|
|
222
|
+
config: './config.ts',
|
|
223
|
+
adapter: Option.none(),
|
|
224
|
+
strategy: Option.none(),
|
|
225
|
+
baseLanguage: Option.none(),
|
|
226
|
+
language: Option.none(),
|
|
227
|
+
name: Option.none(),
|
|
228
|
+
skipNames: false,
|
|
229
|
+
skipLanguages: false,
|
|
230
|
+
fast: false,
|
|
231
|
+
},
|
|
232
|
+
() => Effect.succeed(config),
|
|
233
|
+
() => Effect.fail(new Error('No API key')),
|
|
234
|
+
() => Effect.void,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await expect(Effect.runPromise(program)).rejects.toThrow('No API key');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('fails when translationRunner fails', async () => {
|
|
241
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
242
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
243
|
+
|
|
244
|
+
const program = runTranslate(
|
|
245
|
+
{
|
|
246
|
+
config: './config.ts',
|
|
247
|
+
adapter: Option.none(),
|
|
248
|
+
strategy: Option.none(),
|
|
249
|
+
baseLanguage: Option.none(),
|
|
250
|
+
language: Option.none(),
|
|
251
|
+
name: Option.none(),
|
|
252
|
+
skipNames: false,
|
|
253
|
+
skipLanguages: false,
|
|
254
|
+
fast: false,
|
|
255
|
+
},
|
|
256
|
+
() => Effect.succeed(config),
|
|
257
|
+
() => Effect.succeed({}),
|
|
258
|
+
() => Effect.fail(new Error('Translation failed')),
|
|
259
|
+
() => Effect.void,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
await expect(Effect.runPromise(program)).rejects.toThrow('Translation failed');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('uses one-shot strategy by default', async () => {
|
|
266
|
+
let strategyName: string | undefined;
|
|
267
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
268
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
269
|
+
|
|
270
|
+
const program = runTranslate(
|
|
271
|
+
{
|
|
272
|
+
config: './config.ts',
|
|
273
|
+
adapter: Option.none(),
|
|
274
|
+
strategy: Option.none(),
|
|
275
|
+
baseLanguage: Option.none(),
|
|
276
|
+
language: Option.none(),
|
|
277
|
+
name: Option.none(),
|
|
278
|
+
skipNames: false,
|
|
279
|
+
skipLanguages: false,
|
|
280
|
+
fast: false,
|
|
281
|
+
},
|
|
282
|
+
() => Effect.succeed(config),
|
|
283
|
+
() => Effect.succeed({}),
|
|
284
|
+
(opts) =>
|
|
285
|
+
Effect.sync(() => {
|
|
286
|
+
strategyName = opts.strategy.name;
|
|
287
|
+
}),
|
|
288
|
+
() => Effect.void,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
await Effect.runPromise(program);
|
|
292
|
+
expect(strategyName).toBe('one-shot');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('filters target locales by --language flag', async () => {
|
|
296
|
+
let targetLocales: readonly string[] = [];
|
|
297
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
298
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
299
|
+
|
|
300
|
+
const program = runTranslate(
|
|
301
|
+
{
|
|
302
|
+
config: './config.ts',
|
|
303
|
+
adapter: Option.none(),
|
|
304
|
+
strategy: Option.none(),
|
|
305
|
+
baseLanguage: Option.none(),
|
|
306
|
+
language: Option.some('de'),
|
|
307
|
+
name: Option.none(),
|
|
308
|
+
skipNames: false,
|
|
309
|
+
skipLanguages: false,
|
|
310
|
+
fast: false,
|
|
311
|
+
},
|
|
312
|
+
() => Effect.succeed(config),
|
|
313
|
+
() => Effect.succeed({}),
|
|
314
|
+
(opts) =>
|
|
315
|
+
Effect.sync(() => {
|
|
316
|
+
targetLocales = opts.targetLocales;
|
|
317
|
+
}),
|
|
318
|
+
() => Effect.void,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
await Effect.runPromise(program);
|
|
322
|
+
expect(targetLocales).toEqual(['de']);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('outputs pretty when --format pretty is passed', async () => {
|
|
326
|
+
const logs: string[] = [];
|
|
327
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
328
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
329
|
+
|
|
330
|
+
const program = runTranslate(
|
|
331
|
+
{
|
|
332
|
+
config: './config.ts',
|
|
333
|
+
adapter: Option.none(),
|
|
334
|
+
strategy: Option.none(),
|
|
335
|
+
baseLanguage: Option.none(),
|
|
336
|
+
language: Option.none(),
|
|
337
|
+
name: Option.none(),
|
|
338
|
+
skipNames: false,
|
|
339
|
+
skipLanguages: false,
|
|
340
|
+
fast: false,
|
|
341
|
+
format: Option.some('pretty'),
|
|
342
|
+
},
|
|
343
|
+
() => Effect.succeed(config),
|
|
344
|
+
() => Effect.succeed({}),
|
|
345
|
+
() => Effect.void,
|
|
346
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await Effect.runPromise(program);
|
|
350
|
+
expect(logs).toHaveLength(1);
|
|
351
|
+
expect(logs[0]).toContain('Translation complete');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('handles empty adapters list', async () => {
|
|
355
|
+
const logs: string[] = [];
|
|
356
|
+
const config = { ...baseConfig, adapters: [] };
|
|
357
|
+
|
|
358
|
+
const program = runTranslate(
|
|
359
|
+
{
|
|
360
|
+
config: './config.ts',
|
|
361
|
+
adapter: Option.none(),
|
|
362
|
+
strategy: Option.none(),
|
|
363
|
+
baseLanguage: Option.none(),
|
|
364
|
+
language: Option.none(),
|
|
365
|
+
name: Option.none(),
|
|
366
|
+
skipNames: false,
|
|
367
|
+
skipLanguages: false,
|
|
368
|
+
fast: false,
|
|
369
|
+
},
|
|
370
|
+
() => Effect.succeed(config),
|
|
371
|
+
() => Effect.succeed({}),
|
|
372
|
+
(opts) =>
|
|
373
|
+
Effect.sync(() => {
|
|
374
|
+
expect(opts.adapters).toHaveLength(0);
|
|
375
|
+
}),
|
|
376
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
await Effect.runPromise(program);
|
|
380
|
+
expect(logs).toHaveLength(1);
|
|
381
|
+
const parsed = JSON.parse(logs[0]!);
|
|
382
|
+
expect(parsed.success).toBe(true);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { runTranslation } from '../../translation/orchestrator.js';
|
|
9
|
+
import { detectFormat, type OutputFormat } from '../format.js';
|
|
10
|
+
import { formatTranslate, formatError } from '../formatters.js';
|
|
11
|
+
import type { DialektConfig } from '../../config/types.js';
|
|
12
|
+
import type { TranslationStrategy } from '../../translation/types.js';
|
|
13
|
+
|
|
14
|
+
export interface TranslateFlags {
|
|
15
|
+
readonly config: string;
|
|
16
|
+
readonly adapter: Option.Option<string>;
|
|
17
|
+
readonly strategy: Option.Option<string>;
|
|
18
|
+
readonly baseLanguage: Option.Option<string>;
|
|
19
|
+
readonly language: Option.Option<string>;
|
|
20
|
+
readonly name: Option.Option<string>;
|
|
21
|
+
readonly skipNames: boolean;
|
|
22
|
+
readonly skipLanguages: boolean;
|
|
23
|
+
readonly fast: boolean;
|
|
24
|
+
readonly format?: Option.Option<string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function runTranslate(
|
|
28
|
+
flags: TranslateFlags,
|
|
29
|
+
configLoader: (path: string) => Effect.Effect<DialektConfig, unknown> = loadConfig,
|
|
30
|
+
modelResolver: (config: { provider: string; modelId: string }) => Effect.Effect<unknown, unknown> = resolveModel,
|
|
31
|
+
translationRunner: (opts: {
|
|
32
|
+
adapters: readonly unknown[];
|
|
33
|
+
strategy: TranslationStrategy;
|
|
34
|
+
sourceLocale: string;
|
|
35
|
+
targetLocales: readonly string[];
|
|
36
|
+
chunking: unknown;
|
|
37
|
+
}) => Effect.Effect<void, unknown> = runTranslation as unknown as typeof translationRunner,
|
|
38
|
+
logger: (msg: string) => Effect.Effect<void> = (msg: string) => Console.log(msg),
|
|
39
|
+
): Effect.Effect<void, unknown> {
|
|
40
|
+
return Effect.gen(function* () {
|
|
41
|
+
const loaded = yield* configLoader(flags.config);
|
|
42
|
+
const effective = resolveEffectiveConfig(
|
|
43
|
+
{
|
|
44
|
+
baseLanguage: Option.getOrUndefined(flags.baseLanguage),
|
|
45
|
+
language: Option.isSome(flags.language) ? [flags.language.value] : undefined,
|
|
46
|
+
adapter: Option.getOrUndefined(flags.adapter),
|
|
47
|
+
strategy:
|
|
48
|
+
Option.getOrUndefined(flags.strategy) === 'one-shot' ||
|
|
49
|
+
Option.getOrUndefined(flags.strategy) === 'tool-loop-agent'
|
|
50
|
+
? (Option.getOrUndefined(flags.strategy) as 'one-shot' | 'tool-loop-agent')
|
|
51
|
+
: undefined,
|
|
52
|
+
},
|
|
53
|
+
loaded,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const modelConfig = flags.fast ? effective.fastModel : effective.model;
|
|
57
|
+
const model = yield* modelResolver(modelConfig) as Effect.Effect<import('ai').LanguageModel, unknown>;
|
|
58
|
+
|
|
59
|
+
const translationStrategy =
|
|
60
|
+
effective.strategy === 'tool-loop-agent'
|
|
61
|
+
? createToolLoopStrategy({ model, retry: effective.retry })
|
|
62
|
+
: createOneShotStrategy({ model, retry: effective.retry });
|
|
63
|
+
|
|
64
|
+
yield* translationRunner({
|
|
65
|
+
adapters: effective.adapters,
|
|
66
|
+
strategy: translationStrategy,
|
|
67
|
+
sourceLocale: effective.sourceLocale,
|
|
68
|
+
targetLocales: effective.targetLocales ?? [],
|
|
69
|
+
chunking: effective.chunking,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const format = detectFormat(
|
|
73
|
+
flags.format !== undefined
|
|
74
|
+
? (Option.getOrUndefined(flags.format) as OutputFormat | undefined)
|
|
75
|
+
: undefined,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
yield* logger(
|
|
79
|
+
formatTranslate(
|
|
80
|
+
{
|
|
81
|
+
success: true,
|
|
82
|
+
message: 'Translation complete.',
|
|
83
|
+
stats: {
|
|
84
|
+
adaptersProcessed: effective.adapters.length,
|
|
85
|
+
localesTranslated: (effective.targetLocales ?? []).length,
|
|
86
|
+
keysTranslated: 0, // TODO: track from orchestrator
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
format,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const translateCommand = Command.make('translate', {
|
|
96
|
+
config: Options.text('config').pipe(Options.withDefault('./dialekt.config.ts')),
|
|
97
|
+
adapter: Options.optional(Options.text('adapter')),
|
|
98
|
+
strategy: Options.optional(Options.text('strategy')),
|
|
99
|
+
baseLanguage: Options.optional(Options.text('base-language')),
|
|
100
|
+
language: Options.optional(Options.text('language')),
|
|
101
|
+
name: Options.optional(Options.text('name')),
|
|
102
|
+
skipNames: Options.boolean('skip-names'),
|
|
103
|
+
skipLanguages: Options.boolean('skip-languages'),
|
|
104
|
+
fast: Options.boolean('fast'),
|
|
105
|
+
format: Options.optional(Options.text('format')),
|
|
106
|
+
}, (flags) => runTranslate(flags));
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect, Option } from 'effect';
|
|
3
|
+
import { runUnused, unusedCommand } from './unused.js';
|
|
4
|
+
import type { DialektConfig } from '../../config/types.js';
|
|
5
|
+
import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
|
|
6
|
+
|
|
7
|
+
describe('runUnused', () => {
|
|
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: {
|
|
20
|
+
name: string;
|
|
21
|
+
locales?: readonly string[];
|
|
22
|
+
resources?: readonly ResourceRef[];
|
|
23
|
+
unused?: readonly string[];
|
|
24
|
+
hasUnusedDetection?: boolean;
|
|
25
|
+
}): TranslationAdapter {
|
|
26
|
+
const base = {
|
|
27
|
+
name: opts.name,
|
|
28
|
+
capabilities: {
|
|
29
|
+
canCreateResource: true,
|
|
30
|
+
unusedKeyDetection: opts.hasUnusedDetection ?? true,
|
|
31
|
+
},
|
|
32
|
+
listLocales: () => Effect.succeed(opts.locales ?? ['en', 'de']),
|
|
33
|
+
listResources: () => Effect.succeed(opts.resources ?? [{ key: 'messages', label: 'messages' }]),
|
|
34
|
+
readResource: () => Effect.succeed({}),
|
|
35
|
+
writeResource: () => Effect.void,
|
|
36
|
+
};
|
|
37
|
+
if (opts.unused !== undefined) {
|
|
38
|
+
return {
|
|
39
|
+
...base,
|
|
40
|
+
findUnusedKeys: () => Effect.succeed(opts.unused!),
|
|
41
|
+
} as TranslationAdapter;
|
|
42
|
+
}
|
|
43
|
+
return base as TranslationAdapter;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('logs unused keys for capable adapters', async () => {
|
|
47
|
+
const logs: string[] = [];
|
|
48
|
+
const adapter = makeAdapter({ name: 'test', unused: ['old_key', 'another'] });
|
|
49
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
50
|
+
|
|
51
|
+
const program = runUnused(
|
|
52
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none() },
|
|
53
|
+
() => Effect.succeed(config),
|
|
54
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
55
|
+
() => Effect.void,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await Effect.runPromise(program);
|
|
59
|
+
expect(logs).toHaveLength(1);
|
|
60
|
+
const parsed = JSON.parse(logs[0]!);
|
|
61
|
+
expect(parsed).toEqual([
|
|
62
|
+
{ adapter: 'test', locale: 'en', resource: 'messages', key: 'old_key' },
|
|
63
|
+
{ adapter: 'test', locale: 'en', resource: 'messages', key: 'another' },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('skips adapters without unusedKeyDetection with a warning', async () => {
|
|
68
|
+
const logs: string[] = [];
|
|
69
|
+
const errors: string[] = [];
|
|
70
|
+
const legacy = makeAdapter({ name: 'legacy', hasUnusedDetection: false });
|
|
71
|
+
const config = { ...baseConfig, adapters: [legacy] as unknown as DialektConfig['adapters'] };
|
|
72
|
+
|
|
73
|
+
const program = runUnused(
|
|
74
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none() },
|
|
75
|
+
() => Effect.succeed(config),
|
|
76
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
77
|
+
(msg) => Effect.sync(() => errors.push(msg)),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await Effect.runPromise(program);
|
|
81
|
+
expect(errors).toHaveLength(1);
|
|
82
|
+
expect(JSON.parse(errors[0]!)).toMatchObject({
|
|
83
|
+
error: "Adapter 'legacy' does not support unused-key detection.",
|
|
84
|
+
});
|
|
85
|
+
expect(logs).toHaveLength(1);
|
|
86
|
+
expect(JSON.parse(logs[0]!)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('handles multiple resources', async () => {
|
|
90
|
+
const logs: string[] = [];
|
|
91
|
+
const adapter = makeAdapter({
|
|
92
|
+
name: 'multi',
|
|
93
|
+
resources: [
|
|
94
|
+
{ key: 'auth', label: 'auth' },
|
|
95
|
+
{ key: 'validation', label: 'validation' },
|
|
96
|
+
],
|
|
97
|
+
unused: ['unused_auth'],
|
|
98
|
+
});
|
|
99
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
100
|
+
|
|
101
|
+
const program = runUnused(
|
|
102
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none() },
|
|
103
|
+
() => Effect.succeed(config),
|
|
104
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
105
|
+
() => Effect.void,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await Effect.runPromise(program);
|
|
109
|
+
expect(logs).toHaveLength(1);
|
|
110
|
+
const parsed = JSON.parse(logs[0]!);
|
|
111
|
+
expect(parsed).toContainEqual({ adapter: 'multi', locale: 'en', resource: 'auth', key: 'unused_auth' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('handles empty adapter list', async () => {
|
|
115
|
+
const logs: string[] = [];
|
|
116
|
+
const config = { ...baseConfig, adapters: [] };
|
|
117
|
+
|
|
118
|
+
const program = runUnused(
|
|
119
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none() },
|
|
120
|
+
() => Effect.succeed(config),
|
|
121
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
122
|
+
() => Effect.void,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await Effect.runPromise(program);
|
|
126
|
+
expect(logs).toHaveLength(1);
|
|
127
|
+
expect(JSON.parse(logs[0]!)).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('handles adapter with no resources', async () => {
|
|
131
|
+
const logs: string[] = [];
|
|
132
|
+
const adapter = makeAdapter({ name: 'empty', resources: [] });
|
|
133
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
134
|
+
|
|
135
|
+
const program = runUnused(
|
|
136
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none() },
|
|
137
|
+
() => Effect.succeed(config),
|
|
138
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
139
|
+
() => Effect.void,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
await Effect.runPromise(program);
|
|
143
|
+
expect(logs).toHaveLength(1);
|
|
144
|
+
expect(JSON.parse(logs[0]!)).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('handles no unused keys gracefully', async () => {
|
|
148
|
+
const logs: string[] = [];
|
|
149
|
+
const adapter = makeAdapter({ name: 'clean', unused: [] });
|
|
150
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
151
|
+
|
|
152
|
+
const program = runUnused(
|
|
153
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none() },
|
|
154
|
+
() => Effect.succeed(config),
|
|
155
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
156
|
+
() => Effect.void,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await Effect.runPromise(program);
|
|
160
|
+
expect(logs).toHaveLength(1);
|
|
161
|
+
expect(JSON.parse(logs[0]!)).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('fails when configLoader fails', async () => {
|
|
165
|
+
const program = runUnused(
|
|
166
|
+
{ config: './missing.ts', adapter: Option.none(), baseLanguage: Option.none() },
|
|
167
|
+
() => Effect.fail(new Error('Config not found')),
|
|
168
|
+
() => Effect.void,
|
|
169
|
+
() => Effect.void,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await expect(Effect.runPromise(program)).rejects.toThrow('Config not found');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('outputs pretty when --format pretty is passed', async () => {
|
|
176
|
+
const logs: string[] = [];
|
|
177
|
+
const adapter = makeAdapter({ name: 'test', unused: ['old_key'] });
|
|
178
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
179
|
+
|
|
180
|
+
const program = runUnused(
|
|
181
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), format: Option.some('pretty') },
|
|
182
|
+
() => Effect.succeed(config),
|
|
183
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
184
|
+
() => Effect.void,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
await Effect.runPromise(program);
|
|
188
|
+
expect(logs).toHaveLength(1);
|
|
189
|
+
expect(logs[0]).toContain('test');
|
|
190
|
+
expect(logs[0]).toContain('old_key');
|
|
191
|
+
});
|
|
192
|
+
});
|