dialekt 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -10
- package/TESTING.md +29 -29
- package/dist/cli/main.d.mts +1 -1
- package/dist/cli/main.mjs +549 -362
- package/dist/formatters-De4Q-X1d.mjs +516 -435
- package/dist/index.d.mts +162 -25
- package/dist/index.mjs +119 -34
- package/package.json +3 -3
- package/pnpm-workspace.yaml +3 -3
- package/src/adapter/types.test.ts +57 -57
- package/src/adapter/types.ts +7 -4
- package/src/benchmark/metrics.test.ts +141 -69
- package/src/benchmark/metrics.ts +6 -6
- package/src/benchmark/report.test.ts +38 -38
- package/src/benchmark/report.ts +6 -6
- package/src/benchmark/runner.test.ts +70 -72
- package/src/benchmark/runner.ts +4 -4
- package/src/cli/commands/add.test.ts +90 -109
- package/src/cli/commands/add.ts +40 -28
- package/src/cli/commands/benchmark.test.ts +77 -64
- package/src/cli/commands/benchmark.ts +64 -41
- package/src/cli/commands/languages.test.ts +45 -42
- package/src/cli/commands/languages.ts +16 -12
- package/src/cli/commands/missing.test.ts +143 -92
- package/src/cli/commands/missing.ts +24 -17
- package/src/cli/commands/translate.test.ts +79 -79
- package/src/cli/commands/translate.ts +41 -31
- package/src/cli/commands/unused.test.ts +62 -51
- package/src/cli/commands/unused.ts +18 -14
- package/src/cli/commands/validate.test.ts +130 -72
- package/src/cli/commands/validate.ts +25 -20
- package/src/cli/config-resolution.test.ts +169 -49
- package/src/cli/config-resolution.ts +5 -7
- package/src/cli/format.test.ts +50 -50
- package/src/cli/format.ts +57 -60
- package/src/cli/formatters.test.ts +128 -106
- package/src/cli/formatters.ts +72 -95
- package/src/cli/main.ts +13 -13
- package/src/config/define-config.test.ts +44 -29
- package/src/config/define-config.ts +1 -1
- package/src/config/load-config.test.ts +21 -18
- package/src/config/load-config.ts +5 -5
- package/src/config/types.test.ts +50 -44
- package/src/config/types.ts +2 -2
- package/src/index.ts +22 -26
- package/src/keys/flatten.test.ts +52 -52
- package/src/keys/flatten.ts +7 -9
- package/src/sdk/file-io.test.ts +47 -59
- package/src/sdk/file-io.ts +2 -2
- package/src/sdk/node-layer.test.ts +18 -18
- package/src/sdk/node-layer.ts +2 -2
- package/src/sdk/php-array-reader.test.ts +49 -40
- package/src/sdk/php-array-reader.ts +5 -5
- package/src/translation/chunking.test.ts +52 -44
- package/src/translation/chunking.ts +1 -1
- package/src/translation/missing-keys.test.ts +86 -93
- package/src/translation/missing-keys.ts +4 -6
- package/src/translation/model-registry.test.ts +41 -32
- package/src/translation/model-registry.ts +9 -9
- package/src/translation/one-shot-strategy.test.ts +105 -86
- package/src/translation/one-shot-strategy.ts +10 -12
- package/src/translation/orchestrator.test.ts +90 -101
- package/src/translation/orchestrator.ts +26 -26
- package/src/translation/prompt.test.ts +76 -76
- package/src/translation/prompt.ts +2 -2
- package/src/translation/tool-loop-strategy.test.ts +134 -107
- package/src/translation/tool-loop-strategy.ts +14 -18
- package/src/translation/types.test.ts +22 -22
- package/src/translation/types.ts +3 -3
- package/tsdown.config.ts +3 -3
- package/vitest.config.ts +3 -3
package/src/config/types.test.ts
CHANGED
|
@@ -1,69 +1,75 @@
|
|
|
1
|
-
import { describe, expect, it } from
|
|
2
|
-
import type { DialektConfig, ModelConfig, ChunkingConfig, RetryConfig } from
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { DialektConfig, ModelConfig, ChunkingConfig, RetryConfig } from "./types.js";
|
|
3
3
|
|
|
4
|
-
describe(
|
|
5
|
-
it(
|
|
6
|
-
const providers: ModelConfig[
|
|
4
|
+
describe("config type conformance", () => {
|
|
5
|
+
it("accepts all known model providers", () => {
|
|
6
|
+
const providers: ModelConfig["provider"][] = [
|
|
7
|
+
"openai",
|
|
8
|
+
"anthropic",
|
|
9
|
+
"google",
|
|
10
|
+
"mistral",
|
|
11
|
+
"cohere",
|
|
12
|
+
];
|
|
7
13
|
for (const provider of providers) {
|
|
8
|
-
const m: ModelConfig = { provider, modelId:
|
|
14
|
+
const m: ModelConfig = { provider, modelId: "test-model" };
|
|
9
15
|
expect(m.provider).toBe(provider);
|
|
10
16
|
}
|
|
11
17
|
});
|
|
12
18
|
|
|
13
|
-
it(
|
|
19
|
+
it("accepts any string provider at compile time", () => {
|
|
14
20
|
// provider is typed as string, not a literal union
|
|
15
|
-
const valid: ModelConfig = { provider:
|
|
16
|
-
expect(valid.provider).toBe(
|
|
21
|
+
const valid: ModelConfig = { provider: "unknown-provider", modelId: "x" };
|
|
22
|
+
expect(valid.provider).toBe("unknown-provider");
|
|
17
23
|
});
|
|
18
24
|
|
|
19
|
-
it(
|
|
25
|
+
it("ChunkingConfig enforces positive maxTokens", () => {
|
|
20
26
|
const c: ChunkingConfig = { maxTokens: 1, charsPerToken: 1.0, concurrency: 1 };
|
|
21
27
|
expect(c.maxTokens).toBe(1);
|
|
22
28
|
expect(c.concurrency).toBe(1);
|
|
23
29
|
});
|
|
24
30
|
|
|
25
|
-
it(
|
|
31
|
+
it("RetryConfig enforces positive maxAttempts", () => {
|
|
26
32
|
const r: RetryConfig = { maxAttempts: 1, baseDelayMs: 0 };
|
|
27
33
|
expect(r.maxAttempts).toBe(1);
|
|
28
34
|
expect(r.baseDelayMs).toBe(0);
|
|
29
35
|
});
|
|
30
36
|
|
|
31
|
-
it(
|
|
37
|
+
it("DialektConfig requires sourceLocale and targetLocales", () => {
|
|
32
38
|
const minimal: DialektConfig = {
|
|
33
|
-
sourceLocale:
|
|
34
|
-
targetLocales: [
|
|
35
|
-
strategy:
|
|
36
|
-
model: { provider:
|
|
37
|
-
fastModel: { provider:
|
|
39
|
+
sourceLocale: "en",
|
|
40
|
+
targetLocales: ["de"],
|
|
41
|
+
strategy: "one-shot",
|
|
42
|
+
model: { provider: "openai", modelId: "gpt-4o" },
|
|
43
|
+
fastModel: { provider: "openai", modelId: "gpt-4o-mini" },
|
|
38
44
|
chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
|
|
39
45
|
retry: { maxAttempts: 3, baseDelayMs: 1000 },
|
|
40
46
|
adapters: [],
|
|
41
47
|
};
|
|
42
|
-
expect(minimal.sourceLocale).toBe(
|
|
43
|
-
expect(minimal.targetLocales).toEqual([
|
|
48
|
+
expect(minimal.sourceLocale).toBe("en");
|
|
49
|
+
expect(minimal.targetLocales).toEqual(["de"]);
|
|
44
50
|
});
|
|
45
51
|
|
|
46
|
-
it(
|
|
52
|
+
it("DialektConfig accepts tool-loop-agent strategy", () => {
|
|
47
53
|
const config: DialektConfig = {
|
|
48
|
-
sourceLocale:
|
|
49
|
-
targetLocales: [
|
|
50
|
-
strategy:
|
|
51
|
-
model: { provider:
|
|
52
|
-
fastModel: { provider:
|
|
54
|
+
sourceLocale: "en",
|
|
55
|
+
targetLocales: ["de"],
|
|
56
|
+
strategy: "tool-loop-agent",
|
|
57
|
+
model: { provider: "openai", modelId: "gpt-4o" },
|
|
58
|
+
fastModel: { provider: "openai", modelId: "gpt-4o-mini" },
|
|
53
59
|
chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
|
|
54
60
|
retry: { maxAttempts: 3, baseDelayMs: 1000 },
|
|
55
61
|
adapters: [],
|
|
56
62
|
};
|
|
57
|
-
expect(config.strategy).toBe(
|
|
63
|
+
expect(config.strategy).toBe("tool-loop-agent");
|
|
58
64
|
});
|
|
59
65
|
|
|
60
|
-
it(
|
|
66
|
+
it("targetLocales can be empty", () => {
|
|
61
67
|
const config: DialektConfig = {
|
|
62
|
-
sourceLocale:
|
|
68
|
+
sourceLocale: "en",
|
|
63
69
|
targetLocales: [],
|
|
64
|
-
strategy:
|
|
65
|
-
model: { provider:
|
|
66
|
-
fastModel: { provider:
|
|
70
|
+
strategy: "one-shot",
|
|
71
|
+
model: { provider: "openai", modelId: "gpt-4o" },
|
|
72
|
+
fastModel: { provider: "openai", modelId: "gpt-4o-mini" },
|
|
67
73
|
chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
|
|
68
74
|
retry: { maxAttempts: 3, baseDelayMs: 1000 },
|
|
69
75
|
adapters: [],
|
|
@@ -71,13 +77,13 @@ describe('config type conformance', () => {
|
|
|
71
77
|
expect(config.targetLocales).toEqual([]);
|
|
72
78
|
});
|
|
73
79
|
|
|
74
|
-
it(
|
|
80
|
+
it("accepts multiple target locales", () => {
|
|
75
81
|
const config: DialektConfig = {
|
|
76
|
-
sourceLocale:
|
|
77
|
-
targetLocales: [
|
|
78
|
-
strategy:
|
|
79
|
-
model: { provider:
|
|
80
|
-
fastModel: { provider:
|
|
82
|
+
sourceLocale: "en",
|
|
83
|
+
targetLocales: ["de", "fr", "es", "ja"],
|
|
84
|
+
strategy: "one-shot",
|
|
85
|
+
model: { provider: "openai", modelId: "gpt-4o" },
|
|
86
|
+
fastModel: { provider: "openai", modelId: "gpt-4o-mini" },
|
|
81
87
|
chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
|
|
82
88
|
retry: { maxAttempts: 3, baseDelayMs: 1000 },
|
|
83
89
|
adapters: [],
|
|
@@ -85,17 +91,17 @@ describe('config type conformance', () => {
|
|
|
85
91
|
expect(config.targetLocales).toHaveLength(4);
|
|
86
92
|
});
|
|
87
93
|
|
|
88
|
-
it(
|
|
94
|
+
it("fastModel can be different provider from model", () => {
|
|
89
95
|
const config: DialektConfig = {
|
|
90
|
-
sourceLocale:
|
|
91
|
-
targetLocales: [
|
|
92
|
-
strategy:
|
|
93
|
-
model: { provider:
|
|
94
|
-
fastModel: { provider:
|
|
96
|
+
sourceLocale: "en",
|
|
97
|
+
targetLocales: ["de"],
|
|
98
|
+
strategy: "one-shot",
|
|
99
|
+
model: { provider: "openai", modelId: "gpt-4o" },
|
|
100
|
+
fastModel: { provider: "anthropic", modelId: "claude-3-haiku" },
|
|
95
101
|
chunking: { maxTokens: 3000, charsPerToken: 3.0, concurrency: 3 },
|
|
96
102
|
retry: { maxAttempts: 3, baseDelayMs: 1000 },
|
|
97
103
|
adapters: [],
|
|
98
104
|
};
|
|
99
|
-
expect(config.fastModel.provider).toBe(
|
|
105
|
+
expect(config.fastModel.provider).toBe("anthropic");
|
|
100
106
|
});
|
|
101
107
|
});
|
package/src/config/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TranslationAdapter } from
|
|
1
|
+
import type { TranslationAdapter } from "../adapter/types.js";
|
|
2
2
|
|
|
3
3
|
export interface ModelConfig {
|
|
4
4
|
readonly provider: string;
|
|
@@ -19,7 +19,7 @@ export interface RetryConfig {
|
|
|
19
19
|
export interface DialektConfig {
|
|
20
20
|
readonly sourceLocale: string;
|
|
21
21
|
readonly targetLocales: readonly string[] | null;
|
|
22
|
-
readonly strategy:
|
|
22
|
+
readonly strategy: "one-shot" | "tool-loop-agent";
|
|
23
23
|
readonly model: ModelConfig;
|
|
24
24
|
readonly fastModel: ModelConfig;
|
|
25
25
|
readonly chunking: ChunkingConfig;
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
|
-
export { defineConfig } from
|
|
2
|
-
export type { DialektConfig, ModelConfig, ChunkingConfig, RetryConfig } from
|
|
3
|
-
export type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
10
|
-
export {
|
|
11
|
-
export {
|
|
12
|
-
export {
|
|
13
|
-
export {
|
|
14
|
-
export {
|
|
15
|
-
export
|
|
16
|
-
export {
|
|
17
|
-
export {
|
|
18
|
-
export {
|
|
19
|
-
export { runTranslation } from './translation/orchestrator.js';
|
|
20
|
-
export { buildSystemPrompt, buildUserPrompt } from './translation/prompt.js';
|
|
21
|
-
export { computeMissingKeys } from './translation/missing-keys.js';
|
|
22
|
-
export { loadConfig, ConfigLoadError } from './config/load-config.js';
|
|
1
|
+
export { defineConfig } from "./config/define-config.js";
|
|
2
|
+
export type { DialektConfig, ModelConfig, ChunkingConfig, RetryConfig } from "./config/types.js";
|
|
3
|
+
export type { ResourceRef, TranslationAdapter, AdapterCapabilities } from "./adapter/types.js";
|
|
4
|
+
export { AdapterReadError, AdapterWriteError } from "./adapter/types.js";
|
|
5
|
+
export { flattenObject, unflattenObject, diffKeys } from "./keys/flatten.js";
|
|
6
|
+
export { chunkKeys } from "./translation/chunking.js";
|
|
7
|
+
export { NodePlatformLayer } from "./sdk/node-layer.js";
|
|
8
|
+
export { readFileIfExists, writeFileEnsuringDir } from "./sdk/file-io.js";
|
|
9
|
+
export { readPhpArrayAsJson, PhpExecutionError } from "./sdk/php-array-reader.js";
|
|
10
|
+
export { resolveModel, UnknownProviderError } from "./translation/model-registry.js";
|
|
11
|
+
export type { TranslationContext, TranslationStrategy } from "./translation/types.js";
|
|
12
|
+
export { TranslationFailedError } from "./translation/types.js";
|
|
13
|
+
export { createOneShotStrategy } from "./translation/one-shot-strategy.js";
|
|
14
|
+
export { createToolLoopStrategy } from "./translation/tool-loop-strategy.js";
|
|
15
|
+
export { runTranslation } from "./translation/orchestrator.js";
|
|
16
|
+
export { buildSystemPrompt, buildUserPrompt } from "./translation/prompt.js";
|
|
17
|
+
export { computeMissingKeys } from "./translation/missing-keys.js";
|
|
18
|
+
export { loadConfig, ConfigLoadError } from "./config/load-config.js";
|
|
23
19
|
export {
|
|
24
20
|
detectFormat,
|
|
25
21
|
color,
|
|
@@ -32,8 +28,8 @@ export {
|
|
|
32
28
|
warning,
|
|
33
29
|
info,
|
|
34
30
|
keyValue,
|
|
35
|
-
} from
|
|
36
|
-
export type { OutputFormat } from
|
|
31
|
+
} from "./cli/format.js";
|
|
32
|
+
export type { OutputFormat } from "./cli/format.js";
|
|
37
33
|
export {
|
|
38
34
|
formatMissingKeys,
|
|
39
35
|
formatUnusedKeys,
|
|
@@ -43,7 +39,7 @@ export {
|
|
|
43
39
|
formatAdd,
|
|
44
40
|
formatBenchmark,
|
|
45
41
|
formatError,
|
|
46
|
-
} from
|
|
42
|
+
} from "./cli/formatters.js";
|
|
47
43
|
export type {
|
|
48
44
|
MissingKeyEntry,
|
|
49
45
|
UnusedKeyEntry,
|
|
@@ -53,4 +49,4 @@ export type {
|
|
|
53
49
|
TranslateResult,
|
|
54
50
|
AddResult,
|
|
55
51
|
BenchmarkEntry,
|
|
56
|
-
} from
|
|
52
|
+
} from "./cli/formatters.js";
|
package/src/keys/flatten.test.ts
CHANGED
|
@@ -1,111 +1,111 @@
|
|
|
1
|
-
import { describe, expect, it } from
|
|
2
|
-
import { flattenObject, unflattenObject, diffKeys } from
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { flattenObject, unflattenObject, diffKeys } from "./flatten.js";
|
|
3
3
|
|
|
4
|
-
describe(
|
|
5
|
-
it(
|
|
4
|
+
describe("flattenObject", () => {
|
|
5
|
+
it("flattens nested objects to dot-notation keys", () => {
|
|
6
6
|
expect(
|
|
7
7
|
flattenObject({
|
|
8
|
-
validation: { email:
|
|
8
|
+
validation: { email: "Email", nested: { deep: "x" } },
|
|
9
9
|
}),
|
|
10
|
-
).toEqual({
|
|
10
|
+
).toEqual({ "validation.email": "Email", "validation.nested.deep": "x" });
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
it(
|
|
13
|
+
it("returns {} for an empty object", () => {
|
|
14
14
|
expect(flattenObject({})).toEqual({});
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
it(
|
|
18
|
-
expect(flattenObject({ a:
|
|
17
|
+
it("preserves empty string values", () => {
|
|
18
|
+
expect(flattenObject({ a: "" })).toEqual({ a: "" });
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
it(
|
|
22
|
-
expect(
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
it("handles deeply nested structures", () => {
|
|
22
|
+
expect(flattenObject({ a: { b: { c: { d: { e: "deep" } } } } })).toEqual({
|
|
23
|
+
"a.b.c.d.e": "deep",
|
|
24
|
+
});
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it(
|
|
28
|
-
expect(flattenObject({ items: [
|
|
27
|
+
it("ignores arrays (not flattened)", () => {
|
|
28
|
+
expect(flattenObject({ items: ["first", "second"] })).toEqual({});
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
it(
|
|
32
|
-
expect(flattenObject({ items: [{ name:
|
|
31
|
+
it("ignores mixed arrays and objects", () => {
|
|
32
|
+
expect(flattenObject({ items: [{ name: "a" }, { name: "b" }] })).toEqual({});
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it(
|
|
35
|
+
it("ignores null values", () => {
|
|
36
36
|
expect(flattenObject({ a: null })).toEqual({});
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
it(
|
|
39
|
+
it("ignores numeric values", () => {
|
|
40
40
|
expect(flattenObject({ count: 42 })).toEqual({});
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
it(
|
|
43
|
+
it("ignores boolean values", () => {
|
|
44
44
|
expect(flattenObject({ enabled: true })).toEqual({});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
it(
|
|
48
|
-
expect(flattenObject({ greeting:
|
|
49
|
-
greeting:
|
|
47
|
+
it("preserves unicode values", () => {
|
|
48
|
+
expect(flattenObject({ greeting: "Héllo 🌍" })).toEqual({
|
|
49
|
+
greeting: "Héllo 🌍",
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
describe(
|
|
55
|
-
it(
|
|
56
|
-
expect(unflattenObject({
|
|
57
|
-
validation: { email:
|
|
54
|
+
describe("unflattenObject", () => {
|
|
55
|
+
it("rebuilds nested structure from dot-notation keys", () => {
|
|
56
|
+
expect(unflattenObject({ "validation.email": "Email" })).toEqual({
|
|
57
|
+
validation: { email: "Email" },
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
it(
|
|
62
|
-
const original = { a: { b: { c:
|
|
61
|
+
it("round-trips with flattenObject", () => {
|
|
62
|
+
const original = { a: { b: { c: "1" }, d: "2" } };
|
|
63
63
|
expect(unflattenObject(flattenObject(original))).toEqual(original);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
it(
|
|
67
|
-
expect(
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
it("handles deeply nested keys", () => {
|
|
67
|
+
expect(unflattenObject({ "a.b.c.d.e": "deep" })).toEqual({
|
|
68
|
+
a: { b: { c: { d: { e: "deep" } } } },
|
|
69
|
+
});
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
it(
|
|
73
|
-
expect(unflattenObject({
|
|
74
|
-
items: {
|
|
72
|
+
it("treats numeric keys as object properties, not array indices", () => {
|
|
73
|
+
expect(unflattenObject({ "items.0": "first", "items.1": "second" })).toEqual({
|
|
74
|
+
items: { "0": "first", "1": "second" },
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
it(
|
|
78
|
+
it("handles empty flat object", () => {
|
|
79
79
|
expect(unflattenObject({})).toEqual({});
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it(
|
|
83
|
-
expect(unflattenObject({ a:
|
|
82
|
+
it("handles single-level keys", () => {
|
|
83
|
+
expect(unflattenObject({ a: "1", b: "2" })).toEqual({ a: "1", b: "2" });
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
describe(
|
|
88
|
-
it(
|
|
89
|
-
expect(diffKeys({ a:
|
|
87
|
+
describe("diffKeys", () => {
|
|
88
|
+
it("returns keys present in source but missing in target", () => {
|
|
89
|
+
expect(diffKeys({ a: "1", b: "2" }, { a: "1" })).toEqual(["b"]);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
it(
|
|
93
|
-
expect(diffKeys({ a:
|
|
92
|
+
it("returns [] when target has all source keys", () => {
|
|
93
|
+
expect(diffKeys({ a: "1" }, { a: "1", b: "2" })).toEqual([]);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
it(
|
|
97
|
-
expect(diffKeys({ a:
|
|
96
|
+
it("returns all keys when target is empty", () => {
|
|
97
|
+
expect(diffKeys({ a: "1", b: "2" }, {})).toEqual(["a", "b"]);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
it(
|
|
101
|
-
expect(diffKeys({}, { a:
|
|
100
|
+
it("returns [] when source is empty", () => {
|
|
101
|
+
expect(diffKeys({}, { a: "1" })).toEqual([]);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it(
|
|
105
|
-
expect(diffKeys({ a:
|
|
104
|
+
it("handles keys with different values", () => {
|
|
105
|
+
expect(diffKeys({ a: "1" }, { a: "2" })).toEqual([]);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
it(
|
|
109
|
-
expect(diffKeys({
|
|
108
|
+
it("handles nested flattened keys", () => {
|
|
109
|
+
expect(diffKeys({ "a.b": "1", "a.c": "2" }, { "a.b": "1" })).toEqual(["a.c"]);
|
|
110
110
|
});
|
|
111
111
|
});
|
package/src/keys/flatten.ts
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
1
1
|
export function flattenObject(
|
|
2
2
|
input: Readonly<Record<string, unknown>>,
|
|
3
|
-
prefix =
|
|
3
|
+
prefix = "",
|
|
4
4
|
): Record<string, string> {
|
|
5
5
|
const output: Record<string, string> = {};
|
|
6
6
|
for (const [key, value] of Object.entries(input)) {
|
|
7
|
-
const fullKey = prefix ===
|
|
8
|
-
if (typeof value ===
|
|
7
|
+
const fullKey = prefix === "" ? key : `${prefix}.${key}`;
|
|
8
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
9
9
|
Object.assign(output, flattenObject(value as Record<string, unknown>, fullKey));
|
|
10
|
-
} else if (typeof value ===
|
|
10
|
+
} else if (typeof value === "string") {
|
|
11
11
|
output[fullKey] = value;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
return output;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function unflattenObject(
|
|
18
|
-
input: Readonly<Record<string, string>>,
|
|
19
|
-
): Record<string, unknown> {
|
|
17
|
+
export function unflattenObject(input: Readonly<Record<string, string>>): Record<string, unknown> {
|
|
20
18
|
const output: Record<string, unknown> = {};
|
|
21
19
|
for (const [dottedKey, value] of Object.entries(input)) {
|
|
22
|
-
const segments = dottedKey.split(
|
|
20
|
+
const segments = dottedKey.split(".");
|
|
23
21
|
let cursor = output;
|
|
24
22
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
25
23
|
const segment = segments[i]!;
|
|
26
|
-
if (typeof cursor[segment] !==
|
|
24
|
+
if (typeof cursor[segment] !== "object" || cursor[segment] === null) {
|
|
27
25
|
cursor[segment] = {};
|
|
28
26
|
}
|
|
29
27
|
cursor = cursor[segment] as Record<string, unknown>;
|
package/src/sdk/file-io.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it } from
|
|
2
|
-
import { Effect, Layer } from
|
|
3
|
-
import { FileSystem, Path } from
|
|
4
|
-
import { readFileIfExists, writeFileEnsuringDir } from
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { FileSystem, Path } from "@effect/platform";
|
|
4
|
+
import { readFileIfExists, writeFileEnsuringDir } from "./file-io.js";
|
|
5
5
|
|
|
6
6
|
function makeFsLayer(files: Record<string, string>) {
|
|
7
7
|
const stub = FileSystem.makeNoop({
|
|
@@ -9,9 +9,7 @@ function makeFsLayer(files: Record<string, string>) {
|
|
|
9
9
|
readFileString: (path) =>
|
|
10
10
|
path in files
|
|
11
11
|
? Effect.succeed(files[path]!)
|
|
12
|
-
: Effect.fail(
|
|
13
|
-
new Error(`ENOENT: ${path}`) as never,
|
|
14
|
-
),
|
|
12
|
+
: Effect.fail(new Error(`ENOENT: ${path}`) as never),
|
|
15
13
|
writeFileString: (path, content) => {
|
|
16
14
|
files[path] = content;
|
|
17
15
|
return Effect.void;
|
|
@@ -21,119 +19,109 @@ function makeFsLayer(files: Record<string, string>) {
|
|
|
21
19
|
return Layer.succeed(FileSystem.FileSystem, stub);
|
|
22
20
|
}
|
|
23
21
|
|
|
24
|
-
describe(
|
|
25
|
-
it(
|
|
26
|
-
const files = {
|
|
27
|
-
const program = readFileIfExists(
|
|
28
|
-
Effect.provide(makeFsLayer(files)),
|
|
29
|
-
);
|
|
22
|
+
describe("readFileIfExists", () => {
|
|
23
|
+
it("returns content when file exists", async () => {
|
|
24
|
+
const files = { "/a/b.txt": "hello" };
|
|
25
|
+
const program = readFileIfExists("/a/b.txt").pipe(Effect.provide(makeFsLayer(files)));
|
|
30
26
|
const result = await Effect.runPromise(program);
|
|
31
|
-
expect(result).toBe(
|
|
27
|
+
expect(result).toBe("hello");
|
|
32
28
|
});
|
|
33
29
|
|
|
34
|
-
it(
|
|
35
|
-
const program = readFileIfExists(
|
|
36
|
-
Effect.provide(makeFsLayer({})),
|
|
37
|
-
);
|
|
30
|
+
it("returns null when file does not exist", async () => {
|
|
31
|
+
const program = readFileIfExists("/a/missing.txt").pipe(Effect.provide(makeFsLayer({})));
|
|
38
32
|
const result = await Effect.runPromise(program);
|
|
39
33
|
expect(result).toBeNull();
|
|
40
34
|
});
|
|
41
35
|
|
|
42
|
-
it(
|
|
43
|
-
const program = readFileIfExists(
|
|
44
|
-
Effect.provide(makeFsLayer({})),
|
|
45
|
-
);
|
|
36
|
+
it("returns null for empty file system", async () => {
|
|
37
|
+
const program = readFileIfExists("/any/path.txt").pipe(Effect.provide(makeFsLayer({})));
|
|
46
38
|
const result = await Effect.runPromise(program);
|
|
47
39
|
expect(result).toBeNull();
|
|
48
40
|
});
|
|
49
41
|
|
|
50
|
-
it(
|
|
51
|
-
const files = {
|
|
52
|
-
const program = readFileIfExists(
|
|
42
|
+
it("returns content for deeply nested path", async () => {
|
|
43
|
+
const files = { "/very/deep/nested/file.txt": "deep content" };
|
|
44
|
+
const program = readFileIfExists("/very/deep/nested/file.txt").pipe(
|
|
53
45
|
Effect.provide(makeFsLayer(files)),
|
|
54
46
|
);
|
|
55
47
|
const result = await Effect.runPromise(program);
|
|
56
|
-
expect(result).toBe(
|
|
48
|
+
expect(result).toBe("deep content");
|
|
57
49
|
});
|
|
58
50
|
|
|
59
|
-
it(
|
|
60
|
-
const files = {
|
|
61
|
-
const program = readFileIfExists(
|
|
62
|
-
Effect.provide(makeFsLayer(files)),
|
|
63
|
-
);
|
|
51
|
+
it("handles unicode content", async () => {
|
|
52
|
+
const files = { "/unicode.txt": "Héllo 🌍 — 日本語" };
|
|
53
|
+
const program = readFileIfExists("/unicode.txt").pipe(Effect.provide(makeFsLayer(files)));
|
|
64
54
|
const result = await Effect.runPromise(program);
|
|
65
|
-
expect(result).toBe(
|
|
55
|
+
expect(result).toBe("Héllo 🌍 — 日本語");
|
|
66
56
|
});
|
|
67
57
|
|
|
68
|
-
it(
|
|
69
|
-
const files = {
|
|
70
|
-
const program = readFileIfExists(
|
|
71
|
-
Effect.provide(makeFsLayer(files)),
|
|
72
|
-
);
|
|
58
|
+
it("handles empty string content", async () => {
|
|
59
|
+
const files = { "/empty.txt": "" };
|
|
60
|
+
const program = readFileIfExists("/empty.txt").pipe(Effect.provide(makeFsLayer(files)));
|
|
73
61
|
const result = await Effect.runPromise(program);
|
|
74
|
-
expect(result).toBe(
|
|
62
|
+
expect(result).toBe("");
|
|
75
63
|
});
|
|
76
64
|
});
|
|
77
65
|
|
|
78
|
-
describe(
|
|
79
|
-
it(
|
|
66
|
+
describe("writeFileEnsuringDir", () => {
|
|
67
|
+
it("writes content and creates parent directories", async () => {
|
|
80
68
|
const files: Record<string, string> = {};
|
|
81
|
-
const program = writeFileEnsuringDir(
|
|
69
|
+
const program = writeFileEnsuringDir("/a/b/c.txt", "content").pipe(
|
|
82
70
|
Effect.provide(makeFsLayer(files)),
|
|
83
71
|
Effect.provide(Path.layer),
|
|
84
72
|
);
|
|
85
73
|
await Effect.runPromise(program);
|
|
86
|
-
expect(files[
|
|
74
|
+
expect(files["/a/b/c.txt"]).toBe("content");
|
|
87
75
|
});
|
|
88
76
|
|
|
89
|
-
it(
|
|
90
|
-
const files: Record<string, string> = {
|
|
91
|
-
const program = writeFileEnsuringDir(
|
|
77
|
+
it("overwrites existing file", async () => {
|
|
78
|
+
const files: Record<string, string> = { "/existing.txt": "old" };
|
|
79
|
+
const program = writeFileEnsuringDir("/existing.txt", "new").pipe(
|
|
92
80
|
Effect.provide(makeFsLayer(files)),
|
|
93
81
|
Effect.provide(Path.layer),
|
|
94
82
|
);
|
|
95
83
|
await Effect.runPromise(program);
|
|
96
|
-
expect(files[
|
|
84
|
+
expect(files["/existing.txt"]).toBe("new");
|
|
97
85
|
});
|
|
98
86
|
|
|
99
|
-
it(
|
|
87
|
+
it("handles deeply nested paths", async () => {
|
|
100
88
|
const files: Record<string, string> = {};
|
|
101
|
-
const program = writeFileEnsuringDir(
|
|
89
|
+
const program = writeFileEnsuringDir("/a/b/c/d/e.txt", "nested").pipe(
|
|
102
90
|
Effect.provide(makeFsLayer(files)),
|
|
103
91
|
Effect.provide(Path.layer),
|
|
104
92
|
);
|
|
105
93
|
await Effect.runPromise(program);
|
|
106
|
-
expect(files[
|
|
94
|
+
expect(files["/a/b/c/d/e.txt"]).toBe("nested");
|
|
107
95
|
});
|
|
108
96
|
|
|
109
|
-
it(
|
|
97
|
+
it("handles empty content", async () => {
|
|
110
98
|
const files: Record<string, string> = {};
|
|
111
|
-
const program = writeFileEnsuringDir(
|
|
99
|
+
const program = writeFileEnsuringDir("/empty.txt", "").pipe(
|
|
112
100
|
Effect.provide(makeFsLayer(files)),
|
|
113
101
|
Effect.provide(Path.layer),
|
|
114
102
|
);
|
|
115
103
|
await Effect.runPromise(program);
|
|
116
|
-
expect(files[
|
|
104
|
+
expect(files["/empty.txt"]).toBe("");
|
|
117
105
|
});
|
|
118
106
|
|
|
119
|
-
it(
|
|
107
|
+
it("handles unicode content", async () => {
|
|
120
108
|
const files: Record<string, string> = {};
|
|
121
|
-
const content =
|
|
122
|
-
const program = writeFileEnsuringDir(
|
|
109
|
+
const content = "Héllo 🌍 — 日本語";
|
|
110
|
+
const program = writeFileEnsuringDir("/unicode.txt", content).pipe(
|
|
123
111
|
Effect.provide(makeFsLayer(files)),
|
|
124
112
|
Effect.provide(Path.layer),
|
|
125
113
|
);
|
|
126
114
|
await Effect.runPromise(program);
|
|
127
|
-
expect(files[
|
|
115
|
+
expect(files["/unicode.txt"]).toBe(content);
|
|
128
116
|
});
|
|
129
117
|
|
|
130
|
-
it(
|
|
118
|
+
it("handles paths with spaces", async () => {
|
|
131
119
|
const files: Record<string, string> = {};
|
|
132
|
-
const program = writeFileEnsuringDir(
|
|
120
|
+
const program = writeFileEnsuringDir("/path with spaces/file.txt", "content").pipe(
|
|
133
121
|
Effect.provide(makeFsLayer(files)),
|
|
134
122
|
Effect.provide(Path.layer),
|
|
135
123
|
);
|
|
136
124
|
await Effect.runPromise(program);
|
|
137
|
-
expect(files[
|
|
125
|
+
expect(files["/path with spaces/file.txt"]).toBe("content");
|
|
138
126
|
});
|
|
139
127
|
});
|