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,87 @@
|
|
|
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 { formatUnusedKeys, formatError } from '../formatters.js';
|
|
7
|
+
import type { DialektConfig } from '../../config/types.js';
|
|
8
|
+
import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
|
|
9
|
+
|
|
10
|
+
export interface UnusedFlags {
|
|
11
|
+
readonly config: string;
|
|
12
|
+
readonly adapter: Option.Option<string>;
|
|
13
|
+
readonly baseLanguage: Option.Option<string>;
|
|
14
|
+
readonly format?: Option.Option<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function runUnused(
|
|
18
|
+
flags: UnusedFlags,
|
|
19
|
+
configLoader: (path: string) => Effect.Effect<DialektConfig, unknown> = loadConfig,
|
|
20
|
+
logger: (msg: string) => Effect.Effect<void> = (msg: string) => Console.log(msg),
|
|
21
|
+
errorLogger: (msg: string) => Effect.Effect<void> = (msg: string) => Console.error(msg),
|
|
22
|
+
): Effect.Effect<void, never> {
|
|
23
|
+
return Effect.gen(function* () {
|
|
24
|
+
const loaded = yield* configLoader(flags.config);
|
|
25
|
+
const effective = resolveEffectiveConfig(
|
|
26
|
+
{
|
|
27
|
+
baseLanguage: Option.getOrUndefined(flags.baseLanguage),
|
|
28
|
+
adapter: Option.getOrUndefined(flags.adapter),
|
|
29
|
+
},
|
|
30
|
+
loaded,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const allEntries: Array<{
|
|
34
|
+
adapter: string;
|
|
35
|
+
locale: string;
|
|
36
|
+
resource: string;
|
|
37
|
+
key: string;
|
|
38
|
+
}> = [];
|
|
39
|
+
|
|
40
|
+
for (const a of effective.adapters) {
|
|
41
|
+
if (!a.capabilities.unusedKeyDetection) {
|
|
42
|
+
yield* errorLogger(
|
|
43
|
+
formatError(
|
|
44
|
+
`Adapter '${a.name}' does not support unused-key detection.`,
|
|
45
|
+
detectFormat(
|
|
46
|
+
flags.format !== undefined
|
|
47
|
+
? (Option.getOrUndefined(flags.format) as OutputFormat | undefined)
|
|
48
|
+
: undefined,
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const locales = yield* a.listLocales();
|
|
56
|
+
const sourceLocale = effective.sourceLocale;
|
|
57
|
+
const resources = yield* a.listResources(sourceLocale);
|
|
58
|
+
|
|
59
|
+
for (const resource of resources) {
|
|
60
|
+
const unused = yield* a.findUnusedKeys!(sourceLocale, resource);
|
|
61
|
+
for (const key of unused) {
|
|
62
|
+
allEntries.push({
|
|
63
|
+
adapter: a.name,
|
|
64
|
+
locale: sourceLocale,
|
|
65
|
+
resource: resource.label,
|
|
66
|
+
key,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
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(formatUnusedKeys(allEntries, format));
|
|
79
|
+
}).pipe(Effect.mapError((e) => e as never)) as Effect.Effect<void, never, never>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const unusedCommand = Command.make('unused', {
|
|
83
|
+
config: Options.text('config').pipe(Options.withDefault('./dialekt.config.ts')),
|
|
84
|
+
adapter: Options.optional(Options.text('adapter')),
|
|
85
|
+
baseLanguage: Options.optional(Options.text('base-language')),
|
|
86
|
+
format: Options.optional(Options.text('format')),
|
|
87
|
+
}, (flags) => runUnused(flags));
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Effect, Option } from 'effect';
|
|
3
|
+
import { runValidate, validateCommand } from './validate.js';
|
|
4
|
+
import type { DialektConfig } from '../../config/types.js';
|
|
5
|
+
import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
|
|
6
|
+
|
|
7
|
+
describe('runValidate', () => {
|
|
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
|
+
missing?: readonly { resource: ResourceRef; missing: readonly string[] }[];
|
|
23
|
+
}): TranslationAdapter {
|
|
24
|
+
return {
|
|
25
|
+
name: opts.name,
|
|
26
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
27
|
+
listLocales: () => Effect.succeed(opts.locales ?? ['en', 'de']),
|
|
28
|
+
listResources: () => Effect.succeed([{ key: 'messages', label: 'messages' }]),
|
|
29
|
+
readResource: () => Effect.succeed({}),
|
|
30
|
+
writeResource: () => Effect.void,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('passes when no keys are missing', async () => {
|
|
35
|
+
const logs: string[] = [];
|
|
36
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
37
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
38
|
+
|
|
39
|
+
const program = runValidate(
|
|
40
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none() },
|
|
41
|
+
() => Effect.succeed(config),
|
|
42
|
+
() => Effect.succeed([]),
|
|
43
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
await Effect.runPromise(program);
|
|
47
|
+
expect(logs).toHaveLength(1);
|
|
48
|
+
const parsed = JSON.parse(logs[0]!);
|
|
49
|
+
expect(parsed.passing).toBe(true);
|
|
50
|
+
expect(parsed.entries).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('fails and reports missing keys', async () => {
|
|
54
|
+
const logs: string[] = [];
|
|
55
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
56
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
57
|
+
const resource: ResourceRef = { key: 'messages', label: 'messages' };
|
|
58
|
+
|
|
59
|
+
const originalExitCode = process.exitCode;
|
|
60
|
+
const program = runValidate(
|
|
61
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none() },
|
|
62
|
+
() => Effect.succeed(config),
|
|
63
|
+
() =>
|
|
64
|
+
Effect.succeed([
|
|
65
|
+
{ adapter: 'test', locale: 'de', resource, missing: ['hello'] },
|
|
66
|
+
]),
|
|
67
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await Effect.runPromise(program);
|
|
71
|
+
process.exitCode = originalExitCode;
|
|
72
|
+
|
|
73
|
+
expect(logs).toHaveLength(1);
|
|
74
|
+
const parsed = JSON.parse(logs[0]!);
|
|
75
|
+
expect(parsed.passing).toBe(false);
|
|
76
|
+
expect(parsed.entries).toHaveLength(1);
|
|
77
|
+
expect(parsed.entries[0]).toMatchObject({ adapter: 'test', locale: 'de', resource: 'messages', count: 1 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('reports missing keys for multiple adapters', async () => {
|
|
81
|
+
const logs: string[] = [];
|
|
82
|
+
const a1 = makeAdapter({ name: 'a1' });
|
|
83
|
+
const a2 = makeAdapter({ name: 'a2', locales: ['en', 'fr'] });
|
|
84
|
+
const config = { ...baseConfig, adapters: [a1, a2] as unknown as DialektConfig['adapters'] };
|
|
85
|
+
const resource: ResourceRef = { key: 'messages', label: 'messages' };
|
|
86
|
+
|
|
87
|
+
const originalExitCode = process.exitCode;
|
|
88
|
+
const program = runValidate(
|
|
89
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none() },
|
|
90
|
+
() => Effect.succeed(config),
|
|
91
|
+
(a) =>
|
|
92
|
+
Effect.succeed([
|
|
93
|
+
{ adapter: a.name, locale: a.name === 'a1' ? 'de' : 'fr', resource, missing: ['k1'] },
|
|
94
|
+
]),
|
|
95
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await Effect.runPromise(program);
|
|
99
|
+
process.exitCode = originalExitCode;
|
|
100
|
+
|
|
101
|
+
expect(logs).toHaveLength(1);
|
|
102
|
+
const parsed = JSON.parse(logs[0]!);
|
|
103
|
+
expect(parsed.passing).toBe(false);
|
|
104
|
+
expect(parsed.entries).toHaveLength(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('filters by --adapter flag', async () => {
|
|
108
|
+
let queriedAdapter: string | undefined;
|
|
109
|
+
const a1 = makeAdapter({ name: 'a1' });
|
|
110
|
+
const a2 = makeAdapter({ name: 'a2' });
|
|
111
|
+
const config = { ...baseConfig, adapters: [a1, a2] as unknown as DialektConfig['adapters'] };
|
|
112
|
+
|
|
113
|
+
const program = runValidate(
|
|
114
|
+
{ config: './config.ts', adapter: Option.some('a2'), baseLanguage: Option.none(), language: Option.none() },
|
|
115
|
+
() => Effect.succeed(config),
|
|
116
|
+
(a) =>
|
|
117
|
+
Effect.sync(() => {
|
|
118
|
+
queriedAdapter = a.name;
|
|
119
|
+
return [];
|
|
120
|
+
}),
|
|
121
|
+
() => Effect.void,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
await Effect.runPromise(program);
|
|
125
|
+
expect(queriedAdapter).toBe('a2');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('fails when configLoader fails', async () => {
|
|
129
|
+
const program = runValidate(
|
|
130
|
+
{ config: './missing.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none() },
|
|
131
|
+
() => Effect.fail(new Error('Config not found')),
|
|
132
|
+
() => Effect.succeed([]),
|
|
133
|
+
() => Effect.void,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await expect(Effect.runPromise(program)).rejects.toThrow('Config not found');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('fails when listLocales fails', async () => {
|
|
140
|
+
const adapter: TranslationAdapter = {
|
|
141
|
+
name: 'broken',
|
|
142
|
+
capabilities: { canCreateResource: true, unusedKeyDetection: false },
|
|
143
|
+
listLocales: () => Effect.fail(new Error('disk error') as never),
|
|
144
|
+
listResources: () => Effect.succeed([]),
|
|
145
|
+
readResource: () => Effect.succeed({}),
|
|
146
|
+
writeResource: () => Effect.void,
|
|
147
|
+
};
|
|
148
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
149
|
+
|
|
150
|
+
const program = runValidate(
|
|
151
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none() },
|
|
152
|
+
() => Effect.succeed(config),
|
|
153
|
+
() => Effect.succeed([]),
|
|
154
|
+
() => Effect.void,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await expect(Effect.runPromise(program)).rejects.toThrow('disk error');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('fails when missingKeysComputer fails', async () => {
|
|
161
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
162
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
163
|
+
|
|
164
|
+
const program = runValidate(
|
|
165
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none() },
|
|
166
|
+
() => Effect.succeed(config),
|
|
167
|
+
() => Effect.fail(new Error('read error')),
|
|
168
|
+
() => Effect.void,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await expect(Effect.runPromise(program)).rejects.toThrow('read error');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('filters by --language flag', async () => {
|
|
175
|
+
let checkedLocale: string | undefined;
|
|
176
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
177
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
178
|
+
|
|
179
|
+
const program = runValidate(
|
|
180
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.some('de') },
|
|
181
|
+
() => Effect.succeed(config),
|
|
182
|
+
(_a, _s, targets) =>
|
|
183
|
+
Effect.sync(() => {
|
|
184
|
+
checkedLocale = targets[0];
|
|
185
|
+
return [];
|
|
186
|
+
}),
|
|
187
|
+
() => Effect.void,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await Effect.runPromise(program);
|
|
191
|
+
expect(checkedLocale).toBe('de');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('outputs pretty when --format pretty is passed', async () => {
|
|
195
|
+
const logs: string[] = [];
|
|
196
|
+
const adapter = makeAdapter({ name: 'test' });
|
|
197
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
198
|
+
|
|
199
|
+
const program = runValidate(
|
|
200
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none(), format: Option.some('pretty') },
|
|
201
|
+
() => Effect.succeed(config),
|
|
202
|
+
() => Effect.succeed([]),
|
|
203
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await Effect.runPromise(program);
|
|
207
|
+
expect(logs).toHaveLength(1);
|
|
208
|
+
expect(logs[0]).toContain('up to date');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('handles empty adapter list', async () => {
|
|
212
|
+
const logs: string[] = [];
|
|
213
|
+
const config = { ...baseConfig, adapters: [] };
|
|
214
|
+
|
|
215
|
+
const program = runValidate(
|
|
216
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none() },
|
|
217
|
+
() => Effect.succeed(config),
|
|
218
|
+
() => Effect.succeed([]),
|
|
219
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
await Effect.runPromise(program);
|
|
223
|
+
expect(logs).toHaveLength(1);
|
|
224
|
+
const parsed = JSON.parse(logs[0]!);
|
|
225
|
+
expect(parsed.passing).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('handles adapter with only source locale (no targets)', async () => {
|
|
229
|
+
const logs: string[] = [];
|
|
230
|
+
const adapter = makeAdapter({ name: 'mono', locales: ['en'] });
|
|
231
|
+
const config = { ...baseConfig, adapters: [adapter] as unknown as DialektConfig['adapters'] };
|
|
232
|
+
|
|
233
|
+
const program = runValidate(
|
|
234
|
+
{ config: './config.ts', adapter: Option.none(), baseLanguage: Option.none(), language: Option.none() },
|
|
235
|
+
() => Effect.succeed(config),
|
|
236
|
+
() => Effect.succeed([]),
|
|
237
|
+
(msg) => Effect.sync(() => logs.push(msg)),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
await Effect.runPromise(program);
|
|
241
|
+
expect(logs).toHaveLength(1);
|
|
242
|
+
const parsed = JSON.parse(logs[0]!);
|
|
243
|
+
expect(parsed.passing).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
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 { computeMissingKeys } from '../../translation/missing-keys.js';
|
|
6
|
+
import { detectFormat, type OutputFormat } from '../format.js';
|
|
7
|
+
import { formatValidate, formatError } from '../formatters.js';
|
|
8
|
+
import type { DialektConfig } from '../../config/types.js';
|
|
9
|
+
import type { TranslationAdapter, ResourceRef } from '../../adapter/types.js';
|
|
10
|
+
|
|
11
|
+
export interface ValidateFlags {
|
|
12
|
+
readonly config: string;
|
|
13
|
+
readonly adapter: Option.Option<string>;
|
|
14
|
+
readonly baseLanguage: Option.Option<string>;
|
|
15
|
+
readonly language: Option.Option<string>;
|
|
16
|
+
readonly format?: Option.Option<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MissingEntry {
|
|
20
|
+
readonly adapter: string;
|
|
21
|
+
readonly locale: string;
|
|
22
|
+
readonly resource: ResourceRef;
|
|
23
|
+
readonly missing: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function runValidate(
|
|
27
|
+
flags: ValidateFlags,
|
|
28
|
+
configLoader: (path: string) => Effect.Effect<DialektConfig, unknown> = loadConfig,
|
|
29
|
+
missingKeysComputer: (
|
|
30
|
+
adapter: TranslationAdapter,
|
|
31
|
+
sourceLocale: string,
|
|
32
|
+
targetLocales: readonly string[],
|
|
33
|
+
) => Effect.Effect<readonly MissingEntry[], unknown> = computeMissingKeys as unknown as typeof missingKeysComputer,
|
|
34
|
+
logger: (msg: string) => Effect.Effect<void> = (msg: string) => Console.log(msg),
|
|
35
|
+
): Effect.Effect<void, Error> {
|
|
36
|
+
return Effect.gen(function* () {
|
|
37
|
+
const loaded = yield* configLoader(flags.config);
|
|
38
|
+
const effective = resolveEffectiveConfig(
|
|
39
|
+
{
|
|
40
|
+
baseLanguage: Option.getOrUndefined(flags.baseLanguage),
|
|
41
|
+
language: Option.isSome(flags.language) ? [flags.language.value] : undefined,
|
|
42
|
+
adapter: Option.getOrUndefined(flags.adapter),
|
|
43
|
+
},
|
|
44
|
+
loaded,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const entries: Array<{
|
|
48
|
+
adapter: string;
|
|
49
|
+
locale: string;
|
|
50
|
+
resource: string;
|
|
51
|
+
count: number;
|
|
52
|
+
}> = [];
|
|
53
|
+
|
|
54
|
+
for (const a of effective.adapters) {
|
|
55
|
+
const locales = yield* a.listLocales();
|
|
56
|
+
const sourceLocale = effective.sourceLocale;
|
|
57
|
+
const targets = locales.filter((l) => l !== sourceLocale);
|
|
58
|
+
|
|
59
|
+
const missingEntries = yield* missingKeysComputer(a, sourceLocale, targets);
|
|
60
|
+
for (const entry of missingEntries) {
|
|
61
|
+
entries.push({
|
|
62
|
+
adapter: entry.adapter,
|
|
63
|
+
locale: entry.locale,
|
|
64
|
+
resource: entry.resource.label,
|
|
65
|
+
count: entry.missing.length,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const format = detectFormat(
|
|
71
|
+
flags.format !== undefined
|
|
72
|
+
? (Option.getOrUndefined(flags.format) as OutputFormat | undefined)
|
|
73
|
+
: undefined,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const passing = entries.length === 0;
|
|
77
|
+
|
|
78
|
+
yield* logger(
|
|
79
|
+
formatValidate({ passing, entries }, format),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!passing) {
|
|
83
|
+
yield* Effect.sync(() => {
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}).pipe(Effect.mapError((e) => e as Error)) as Effect.Effect<void, Error, never>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const validateCommand = Command.make('validate', {
|
|
91
|
+
config: Options.text('config').pipe(Options.withDefault('./dialekt.config.ts')),
|
|
92
|
+
adapter: Options.optional(Options.text('adapter')),
|
|
93
|
+
baseLanguage: Options.optional(Options.text('base-language')),
|
|
94
|
+
language: Options.optional(Options.text('language')),
|
|
95
|
+
format: Options.optional(Options.text('format')),
|
|
96
|
+
}, (flags) => runValidate(flags));
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveEffectiveConfig } from './config-resolution.js';
|
|
3
|
+
import type { DialektConfig } from '../config/types.js';
|
|
4
|
+
|
|
5
|
+
const baseConfig: DialektConfig = {
|
|
6
|
+
sourceLocale: 'en',
|
|
7
|
+
targetLocales: ['de', 'fr'],
|
|
8
|
+
strategy: 'one-shot',
|
|
9
|
+
model: { provider: 'openai', modelId: 'gpt-4o' },
|
|
10
|
+
fastModel: { provider: 'openai', modelId: 'gpt-4o-mini' },
|
|
11
|
+
chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
|
|
12
|
+
retry: { maxAttempts: 3, baseDelayMs: 1000 },
|
|
13
|
+
adapters: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('resolveEffectiveConfig', () => {
|
|
17
|
+
it('uses loaded config when no flags given', () => {
|
|
18
|
+
const result = resolveEffectiveConfig({}, baseConfig);
|
|
19
|
+
expect(result.sourceLocale).toBe('en');
|
|
20
|
+
expect(result.targetLocales).toEqual(['de', 'fr']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('overrides sourceLocale with flag', () => {
|
|
24
|
+
const result = resolveEffectiveConfig({ baseLanguage: 'fr' }, baseConfig);
|
|
25
|
+
expect(result.sourceLocale).toBe('fr');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('overrides strategy with flag', () => {
|
|
29
|
+
const result = resolveEffectiveConfig({ strategy: 'tool-loop-agent' }, baseConfig);
|
|
30
|
+
expect(result.strategy).toBe('tool-loop-agent');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('filters adapters by name flag', () => {
|
|
34
|
+
const adapterA = { name: 'a', capabilities: { canCreateResource: true, unusedKeyDetection: false }, listLocales: () => { throw new Error(); }, listResources: () => { throw new Error(); }, readResource: () => { throw new Error(); }, writeResource: () => { throw new Error(); } } as unknown as import('../config/types.js').DialektConfig['adapters'][number];
|
|
35
|
+
const adapterB = { name: 'b', capabilities: { canCreateResource: true, unusedKeyDetection: false }, listLocales: () => { throw new Error(); }, listResources: () => { throw new Error(); }, readResource: () => { throw new Error(); }, writeResource: () => { throw new Error(); } } as unknown as import('../config/types.js').DialektConfig['adapters'][number];
|
|
36
|
+
const config = { ...baseConfig, adapters: [adapterA, adapterB] };
|
|
37
|
+
const result = resolveEffectiveConfig({ adapter: 'b' }, config);
|
|
38
|
+
expect(result.adapters).toHaveLength(1);
|
|
39
|
+
expect(result.adapters[0]!.name).toBe('b');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns all adapters when adapter flag does not match any', () => {
|
|
43
|
+
const adapterA = { name: 'a', capabilities: { canCreateResource: true, unusedKeyDetection: false }, listLocales: () => { throw new Error(); }, listResources: () => { throw new Error(); }, readResource: () => { throw new Error(); }, writeResource: () => { throw new Error(); } } as unknown as import('../config/types.js').DialektConfig['adapters'][number];
|
|
44
|
+
const config = { ...baseConfig, adapters: [adapterA] };
|
|
45
|
+
const result = resolveEffectiveConfig({ adapter: 'nonexistent' }, config);
|
|
46
|
+
expect(result.adapters).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('overrides targetLocales with language flag', () => {
|
|
50
|
+
const result = resolveEffectiveConfig({ language: ['es'] }, baseConfig);
|
|
51
|
+
expect(result.targetLocales).toEqual(['es']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('preserves targetLocales when language flag is empty', () => {
|
|
55
|
+
const result = resolveEffectiveConfig({ language: [] }, baseConfig);
|
|
56
|
+
expect(result.targetLocales).toEqual(['de', 'fr']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('preserves targetLocales when language flag is undefined', () => {
|
|
60
|
+
const result = resolveEffectiveConfig({}, baseConfig);
|
|
61
|
+
expect(result.targetLocales).toEqual(['de', 'fr']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not override strategy when flag is undefined', () => {
|
|
65
|
+
const result = resolveEffectiveConfig({}, baseConfig);
|
|
66
|
+
expect(result.strategy).toBe('one-shot');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not override sourceLocale when baseLanguage is undefined', () => {
|
|
70
|
+
const result = resolveEffectiveConfig({}, baseConfig);
|
|
71
|
+
expect(result.sourceLocale).toBe('en');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('preserves other config fields unchanged', () => {
|
|
75
|
+
const result = resolveEffectiveConfig({}, baseConfig);
|
|
76
|
+
expect(result.model).toEqual(baseConfig.model);
|
|
77
|
+
expect(result.fastModel).toEqual(baseConfig.fastModel);
|
|
78
|
+
expect(result.chunking).toEqual(baseConfig.chunking);
|
|
79
|
+
expect(result.retry).toEqual(baseConfig.retry);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('handles multiple adapter filters matching one', () => {
|
|
83
|
+
const a1 = { name: 'laravel', capabilities: { canCreateResource: true, unusedKeyDetection: false }, listLocales: () => { throw new Error(); }, listResources: () => { throw new Error(); }, readResource: () => { throw new Error(); }, writeResource: () => { throw new Error(); } } as unknown as import('../config/types.js').DialektConfig['adapters'][number];
|
|
84
|
+
const a2 = { name: 'paraglide', capabilities: { canCreateResource: true, unusedKeyDetection: false }, listLocales: () => { throw new Error(); }, listResources: () => { throw new Error(); }, readResource: () => { throw new Error(); }, writeResource: () => { throw new Error(); } } as unknown as import('../config/types.js').DialektConfig['adapters'][number];
|
|
85
|
+
const config = { ...baseConfig, adapters: [a1, a2] };
|
|
86
|
+
const result = resolveEffectiveConfig({ adapter: 'laravel' }, config);
|
|
87
|
+
expect(result.adapters).toHaveLength(1);
|
|
88
|
+
expect(result.adapters[0]!.name).toBe('laravel');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('preserves adapter order after filtering', () => {
|
|
92
|
+
const a1 = { name: 'first', capabilities: { canCreateResource: true, unusedKeyDetection: false }, listLocales: () => { throw new Error(); }, listResources: () => { throw new Error(); }, readResource: () => { throw new Error(); }, writeResource: () => { throw new Error(); } } as unknown as import('../config/types.js').DialektConfig['adapters'][number];
|
|
93
|
+
const a2 = { name: 'second', capabilities: { canCreateResource: true, unusedKeyDetection: false }, listLocales: () => { throw new Error(); }, listResources: () => { throw new Error(); }, readResource: () => { throw new Error(); }, writeResource: () => { throw new Error(); } } as unknown as import('../config/types.js').DialektConfig['adapters'][number];
|
|
94
|
+
const a3 = { name: 'third', capabilities: { canCreateResource: true, unusedKeyDetection: false }, listLocales: () => { throw new Error(); }, listResources: () => { throw new Error(); }, readResource: () => { throw new Error(); }, writeResource: () => { throw new Error(); } } as unknown as import('../config/types.js').DialektConfig['adapters'][number];
|
|
95
|
+
const config = { ...baseConfig, adapters: [a1, a2, a3] };
|
|
96
|
+
const result = resolveEffectiveConfig({ adapter: 'second' }, config);
|
|
97
|
+
expect(result.adapters[0]!.name).toBe('second');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DialektConfig } from '../config/types.js';
|
|
2
|
+
|
|
3
|
+
export interface CliFlags {
|
|
4
|
+
readonly config?: string | undefined;
|
|
5
|
+
readonly adapter?: string | undefined;
|
|
6
|
+
readonly strategy?: 'one-shot' | 'tool-loop-agent' | undefined;
|
|
7
|
+
readonly baseLanguage?: string | undefined;
|
|
8
|
+
readonly language?: readonly string[] | undefined;
|
|
9
|
+
readonly name?: readonly string[] | undefined;
|
|
10
|
+
readonly skipNames?: boolean | undefined;
|
|
11
|
+
readonly skipLanguages?: boolean | undefined;
|
|
12
|
+
readonly fast?: boolean | undefined;
|
|
13
|
+
readonly langDir?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveEffectiveConfig(
|
|
17
|
+
flags: CliFlags,
|
|
18
|
+
loaded: DialektConfig,
|
|
19
|
+
): DialektConfig {
|
|
20
|
+
return {
|
|
21
|
+
...loaded,
|
|
22
|
+
sourceLocale: flags.baseLanguage ?? loaded.sourceLocale,
|
|
23
|
+
targetLocales: flags.language && flags.language.length > 0 ? flags.language : loaded.targetLocales,
|
|
24
|
+
strategy: flags.strategy ?? loaded.strategy,
|
|
25
|
+
adapters: flags.adapter
|
|
26
|
+
? loaded.adapters.filter((a: { name: string }) => a.name === flags.adapter)
|
|
27
|
+
: loaded.adapters,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
detectFormat,
|
|
4
|
+
color,
|
|
5
|
+
drawTable,
|
|
6
|
+
banner,
|
|
7
|
+
sectionHeader,
|
|
8
|
+
success,
|
|
9
|
+
failure,
|
|
10
|
+
warning,
|
|
11
|
+
info,
|
|
12
|
+
keyValue,
|
|
13
|
+
} from './format.js';
|
|
14
|
+
|
|
15
|
+
describe('detectFormat', () => {
|
|
16
|
+
const originalTty = process.stdout.isTTY;
|
|
17
|
+
const originalEnv = { ...process.env };
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
Object.assign(process.stdout, { isTTY: originalTty });
|
|
21
|
+
for (const key of Object.keys(process.env)) {
|
|
22
|
+
if (!(key in originalEnv)) delete (process.env as Record<string, string | undefined>)[key];
|
|
23
|
+
}
|
|
24
|
+
Object.assign(process.env, originalEnv);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns explicit format when provided', () => {
|
|
28
|
+
expect(detectFormat('json')).toBe('json');
|
|
29
|
+
expect(detectFormat('pretty')).toBe('pretty');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('defaults to json when not a TTY', () => {
|
|
33
|
+
Object.assign(process.stdout, { isTTY: false });
|
|
34
|
+
expect(detectFormat()).toBe('json');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('defaults to pretty when TTY and no agent env', () => {
|
|
38
|
+
Object.assign(process.stdout, { isTTY: true });
|
|
39
|
+
expect(detectFormat()).toBe('pretty');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('defaults to json when agent env var is set', () => {
|
|
43
|
+
Object.assign(process.stdout, { isTTY: true });
|
|
44
|
+
process.env.CLAUDE_CODE = '1';
|
|
45
|
+
expect(detectFormat()).toBe('json');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('color', () => {
|
|
50
|
+
it('returns bare text when not a TTY', () => {
|
|
51
|
+
expect(color('hello', '\x1b[31m')).toBe('hello');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('drawTable', () => {
|
|
56
|
+
it('renders a table with headers and rows', () => {
|
|
57
|
+
const table = drawTable(
|
|
58
|
+
['Name', 'Score'],
|
|
59
|
+
[
|
|
60
|
+
['Alice', '10'],
|
|
61
|
+
['Bob', '8'],
|
|
62
|
+
],
|
|
63
|
+
);
|
|
64
|
+
expect(table).toContain('Name');
|
|
65
|
+
expect(table).toContain('Score');
|
|
66
|
+
expect(table).toContain('Alice');
|
|
67
|
+
expect(table).toContain('Bob');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('handles empty rows', () => {
|
|
71
|
+
const table = drawTable(['A'], []);
|
|
72
|
+
expect(table).toContain('A');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('banner', () => {
|
|
77
|
+
it('includes the title', () => {
|
|
78
|
+
expect(banner('Results')).toContain('Results');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('sectionHeader', () => {
|
|
83
|
+
it('includes the label', () => {
|
|
84
|
+
expect(sectionHeader('Missing keys')).toContain('Missing keys');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('success', () => {
|
|
89
|
+
it('includes the text', () => {
|
|
90
|
+
expect(success('Done')).toContain('Done');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('failure', () => {
|
|
95
|
+
it('includes the text', () => {
|
|
96
|
+
expect(failure('Failed')).toContain('Failed');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('warning', () => {
|
|
101
|
+
it('includes the text', () => {
|
|
102
|
+
expect(warning('Warn')).toContain('Warn');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('info', () => {
|
|
107
|
+
it('returns dimmed text', () => {
|
|
108
|
+
expect(info('note')).toBe('note');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('keyValue', () => {
|
|
113
|
+
it('formats key and value', () => {
|
|
114
|
+
expect(keyValue('Name:', 'test')).toContain('Name:');
|
|
115
|
+
expect(keyValue('Name:', 'test')).toContain('test');
|
|
116
|
+
});
|
|
117
|
+
});
|