dialekt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +62 -0
  2. package/TESTING.md +66 -0
  3. package/dist/cli/main.d.mts +1 -0
  4. package/dist/cli/main.mjs +412 -0
  5. package/dist/formatters-De4Q-X1d.mjs +577 -0
  6. package/dist/index.d.mts +329 -0
  7. package/dist/index.mjs +60 -0
  8. package/package.json +39 -0
  9. package/pnpm-workspace.yaml +7 -0
  10. package/src/adapter/types.test.ts +98 -0
  11. package/src/adapter/types.ts +73 -0
  12. package/src/benchmark/metrics.test.ts +180 -0
  13. package/src/benchmark/metrics.ts +69 -0
  14. package/src/benchmark/report.test.ts +129 -0
  15. package/src/benchmark/report.ts +21 -0
  16. package/src/benchmark/runner.test.ts +162 -0
  17. package/src/benchmark/runner.ts +27 -0
  18. package/src/cli/commands/add.test.ts +267 -0
  19. package/src/cli/commands/add.ts +123 -0
  20. package/src/cli/commands/benchmark.test.ts +346 -0
  21. package/src/cli/commands/benchmark.ts +148 -0
  22. package/src/cli/commands/languages.test.ts +127 -0
  23. package/src/cli/commands/languages.ts +42 -0
  24. package/src/cli/commands/missing.test.ts +256 -0
  25. package/src/cli/commands/missing.ts +88 -0
  26. package/src/cli/commands/translate.test.ts +384 -0
  27. package/src/cli/commands/translate.ts +106 -0
  28. package/src/cli/commands/unused.test.ts +192 -0
  29. package/src/cli/commands/unused.ts +87 -0
  30. package/src/cli/commands/validate.test.ts +245 -0
  31. package/src/cli/commands/validate.ts +96 -0
  32. package/src/cli/config-resolution.test.ts +99 -0
  33. package/src/cli/config-resolution.ts +29 -0
  34. package/src/cli/format.test.ts +117 -0
  35. package/src/cli/format.ts +205 -0
  36. package/src/cli/formatters.test.ts +186 -0
  37. package/src/cli/formatters.ts +350 -0
  38. package/src/cli/main.ts +31 -0
  39. package/src/config/define-config.test.ts +66 -0
  40. package/src/config/define-config.ts +5 -0
  41. package/src/config/load-config.test.ts +35 -0
  42. package/src/config/load-config.ts +21 -0
  43. package/src/config/types.test.ts +101 -0
  44. package/src/config/types.ts +28 -0
  45. package/src/index.ts +56 -0
  46. package/src/keys/flatten.test.ts +111 -0
  47. package/src/keys/flatten.ts +41 -0
  48. package/src/sdk/file-io.test.ts +139 -0
  49. package/src/sdk/file-io.ts +21 -0
  50. package/src/sdk/node-layer.test.ts +54 -0
  51. package/src/sdk/node-layer.ts +10 -0
  52. package/src/sdk/php-array-reader.test.ts +114 -0
  53. package/src/sdk/php-array-reader.ts +26 -0
  54. package/src/translation/chunking.test.ts +118 -0
  55. package/src/translation/chunking.ts +57 -0
  56. package/src/translation/missing-keys.test.ts +179 -0
  57. package/src/translation/missing-keys.ts +36 -0
  58. package/src/translation/model-registry.test.ts +54 -0
  59. package/src/translation/model-registry.ts +43 -0
  60. package/src/translation/one-shot-strategy.test.ts +259 -0
  61. package/src/translation/one-shot-strategy.ts +48 -0
  62. package/src/translation/orchestrator.test.ts +276 -0
  63. package/src/translation/orchestrator.ts +83 -0
  64. package/src/translation/prompt.test.ts +149 -0
  65. package/src/translation/prompt.ts +42 -0
  66. package/src/translation/tool-loop-strategy.test.ts +279 -0
  67. package/src/translation/tool-loop-strategy.ts +68 -0
  68. package/src/translation/types.test.ts +37 -0
  69. package/src/translation/types.ts +21 -0
  70. package/tsconfig.json +9 -0
  71. package/tsconfig.tsbuildinfo +1 -0
  72. package/tsdown.config.ts +7 -0
  73. package/vitest.config.ts +8 -0
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ export { defineConfig } from './config/define-config.js';
2
+ export type { DialektConfig, ModelConfig, ChunkingConfig, RetryConfig } from './config/types.js';
3
+ export type {
4
+ ResourceRef,
5
+ TranslationAdapter,
6
+ AdapterCapabilities,
7
+ } from './adapter/types.js';
8
+ export { AdapterReadError, AdapterWriteError } from './adapter/types.js';
9
+ export { flattenObject, unflattenObject, diffKeys } from './keys/flatten.js';
10
+ export { chunkKeys } from './translation/chunking.js';
11
+ export { NodePlatformLayer } from './sdk/node-layer.js';
12
+ export { readFileIfExists, writeFileEnsuringDir } from './sdk/file-io.js';
13
+ export { readPhpArrayAsJson, PhpExecutionError } from './sdk/php-array-reader.js';
14
+ export { resolveModel, UnknownProviderError } from './translation/model-registry.js';
15
+ export type { TranslationContext, TranslationStrategy } from './translation/types.js';
16
+ export { TranslationFailedError } from './translation/types.js';
17
+ export { createOneShotStrategy } from './translation/one-shot-strategy.js';
18
+ export { createToolLoopStrategy } from './translation/tool-loop-strategy.js';
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';
23
+ export {
24
+ detectFormat,
25
+ color,
26
+ glyphs,
27
+ drawTable,
28
+ banner,
29
+ sectionHeader,
30
+ success,
31
+ failure,
32
+ warning,
33
+ info,
34
+ keyValue,
35
+ } from './cli/format.js';
36
+ export type { OutputFormat } from './cli/format.js';
37
+ export {
38
+ formatMissingKeys,
39
+ formatUnusedKeys,
40
+ formatValidate,
41
+ formatLanguages,
42
+ formatTranslate,
43
+ formatAdd,
44
+ formatBenchmark,
45
+ formatError,
46
+ } from './cli/formatters.js';
47
+ export type {
48
+ MissingKeyEntry,
49
+ UnusedKeyEntry,
50
+ ValidateEntry,
51
+ ValidateResult,
52
+ LanguageEntry,
53
+ TranslateResult,
54
+ AddResult,
55
+ BenchmarkEntry,
56
+ } from './cli/formatters.js';
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { flattenObject, unflattenObject, diffKeys } from './flatten.js';
3
+
4
+ describe('flattenObject', () => {
5
+ it('flattens nested objects to dot-notation keys', () => {
6
+ expect(
7
+ flattenObject({
8
+ validation: { email: 'Email', nested: { deep: 'x' } },
9
+ }),
10
+ ).toEqual({ 'validation.email': 'Email', 'validation.nested.deep': 'x' });
11
+ });
12
+
13
+ it('returns {} for an empty object', () => {
14
+ expect(flattenObject({})).toEqual({});
15
+ });
16
+
17
+ it('preserves empty string values', () => {
18
+ expect(flattenObject({ a: '' })).toEqual({ a: '' });
19
+ });
20
+
21
+ it('handles deeply nested structures', () => {
22
+ expect(
23
+ flattenObject({ a: { b: { c: { d: { e: 'deep' } } } } }),
24
+ ).toEqual({ 'a.b.c.d.e': 'deep' });
25
+ });
26
+
27
+ it('ignores arrays (not flattened)', () => {
28
+ expect(flattenObject({ items: ['first', 'second'] })).toEqual({});
29
+ });
30
+
31
+ it('ignores mixed arrays and objects', () => {
32
+ expect(flattenObject({ items: [{ name: 'a' }, { name: 'b' }] })).toEqual({});
33
+ });
34
+
35
+ it('ignores null values', () => {
36
+ expect(flattenObject({ a: null })).toEqual({});
37
+ });
38
+
39
+ it('ignores numeric values', () => {
40
+ expect(flattenObject({ count: 42 })).toEqual({});
41
+ });
42
+
43
+ it('ignores boolean values', () => {
44
+ expect(flattenObject({ enabled: true })).toEqual({});
45
+ });
46
+
47
+ it('preserves unicode values', () => {
48
+ expect(flattenObject({ greeting: 'Héllo 🌍' })).toEqual({
49
+ greeting: 'Héllo 🌍',
50
+ });
51
+ });
52
+ });
53
+
54
+ describe('unflattenObject', () => {
55
+ it('rebuilds nested structure from dot-notation keys', () => {
56
+ expect(unflattenObject({ 'validation.email': 'Email' })).toEqual({
57
+ validation: { email: 'Email' },
58
+ });
59
+ });
60
+
61
+ it('round-trips with flattenObject', () => {
62
+ const original = { a: { b: { c: '1' }, d: '2' } };
63
+ expect(unflattenObject(flattenObject(original))).toEqual(original);
64
+ });
65
+
66
+ it('handles deeply nested keys', () => {
67
+ expect(
68
+ unflattenObject({ 'a.b.c.d.e': 'deep' }),
69
+ ).toEqual({ a: { b: { c: { d: { e: 'deep' } } } } });
70
+ });
71
+
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
+ });
76
+ });
77
+
78
+ it('handles empty flat object', () => {
79
+ expect(unflattenObject({})).toEqual({});
80
+ });
81
+
82
+ it('handles single-level keys', () => {
83
+ expect(unflattenObject({ a: '1', b: '2' })).toEqual({ a: '1', b: '2' });
84
+ });
85
+ });
86
+
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
+ });
91
+
92
+ it('returns [] when target has all source keys', () => {
93
+ expect(diffKeys({ a: '1' }, { a: '1', b: '2' })).toEqual([]);
94
+ });
95
+
96
+ it('returns all keys when target is empty', () => {
97
+ expect(diffKeys({ a: '1', b: '2' }, {})).toEqual(['a', 'b']);
98
+ });
99
+
100
+ it('returns [] when source is empty', () => {
101
+ expect(diffKeys({}, { a: '1' })).toEqual([]);
102
+ });
103
+
104
+ it('handles keys with different values', () => {
105
+ expect(diffKeys({ a: '1' }, { a: '2' })).toEqual([]);
106
+ });
107
+
108
+ it('handles nested flattened keys', () => {
109
+ expect(diffKeys({ 'a.b': '1', 'a.c': '2' }, { 'a.b': '1' })).toEqual(['a.c']);
110
+ });
111
+ });
@@ -0,0 +1,41 @@
1
+ export function flattenObject(
2
+ input: Readonly<Record<string, unknown>>,
3
+ prefix = '',
4
+ ): Record<string, string> {
5
+ const output: Record<string, string> = {};
6
+ for (const [key, value] of Object.entries(input)) {
7
+ const fullKey = prefix === '' ? key : `${prefix}.${key}`;
8
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
9
+ Object.assign(output, flattenObject(value as Record<string, unknown>, fullKey));
10
+ } else if (typeof value === 'string') {
11
+ output[fullKey] = value;
12
+ }
13
+ }
14
+ return output;
15
+ }
16
+
17
+ export function unflattenObject(
18
+ input: Readonly<Record<string, string>>,
19
+ ): Record<string, unknown> {
20
+ const output: Record<string, unknown> = {};
21
+ for (const [dottedKey, value] of Object.entries(input)) {
22
+ const segments = dottedKey.split('.');
23
+ let cursor = output;
24
+ for (let i = 0; i < segments.length - 1; i++) {
25
+ const segment = segments[i]!;
26
+ if (typeof cursor[segment] !== 'object' || cursor[segment] === null) {
27
+ cursor[segment] = {};
28
+ }
29
+ cursor = cursor[segment] as Record<string, unknown>;
30
+ }
31
+ cursor[segments[segments.length - 1]!] = value;
32
+ }
33
+ return output;
34
+ }
35
+
36
+ export function diffKeys(
37
+ source: Readonly<Record<string, string>>,
38
+ target: Readonly<Record<string, string>>,
39
+ ): string[] {
40
+ return Object.keys(source).filter((key) => !(key in target));
41
+ }
@@ -0,0 +1,139 @@
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
+
6
+ function makeFsLayer(files: Record<string, string>) {
7
+ const stub = FileSystem.makeNoop({
8
+ exists: (path) => Effect.succeed(path in files),
9
+ readFileString: (path) =>
10
+ path in files
11
+ ? Effect.succeed(files[path]!)
12
+ : Effect.fail(
13
+ new Error(`ENOENT: ${path}`) as never,
14
+ ),
15
+ writeFileString: (path, content) => {
16
+ files[path] = content;
17
+ return Effect.void;
18
+ },
19
+ makeDirectory: () => Effect.void,
20
+ });
21
+ return Layer.succeed(FileSystem.FileSystem, stub);
22
+ }
23
+
24
+ describe('readFileIfExists', () => {
25
+ it('returns content when file exists', async () => {
26
+ const files = { '/a/b.txt': 'hello' };
27
+ const program = readFileIfExists('/a/b.txt').pipe(
28
+ Effect.provide(makeFsLayer(files)),
29
+ );
30
+ const result = await Effect.runPromise(program);
31
+ expect(result).toBe('hello');
32
+ });
33
+
34
+ it('returns null when file does not exist', async () => {
35
+ const program = readFileIfExists('/a/missing.txt').pipe(
36
+ Effect.provide(makeFsLayer({})),
37
+ );
38
+ const result = await Effect.runPromise(program);
39
+ expect(result).toBeNull();
40
+ });
41
+
42
+ it('returns null for empty file system', async () => {
43
+ const program = readFileIfExists('/any/path.txt').pipe(
44
+ Effect.provide(makeFsLayer({})),
45
+ );
46
+ const result = await Effect.runPromise(program);
47
+ expect(result).toBeNull();
48
+ });
49
+
50
+ it('returns content for deeply nested path', async () => {
51
+ const files = { '/very/deep/nested/file.txt': 'deep content' };
52
+ const program = readFileIfExists('/very/deep/nested/file.txt').pipe(
53
+ Effect.provide(makeFsLayer(files)),
54
+ );
55
+ const result = await Effect.runPromise(program);
56
+ expect(result).toBe('deep content');
57
+ });
58
+
59
+ it('handles unicode content', async () => {
60
+ const files = { '/unicode.txt': 'Héllo 🌍 — 日本語' };
61
+ const program = readFileIfExists('/unicode.txt').pipe(
62
+ Effect.provide(makeFsLayer(files)),
63
+ );
64
+ const result = await Effect.runPromise(program);
65
+ expect(result).toBe('Héllo 🌍 — 日本語');
66
+ });
67
+
68
+ it('handles empty string content', async () => {
69
+ const files = { '/empty.txt': '' };
70
+ const program = readFileIfExists('/empty.txt').pipe(
71
+ Effect.provide(makeFsLayer(files)),
72
+ );
73
+ const result = await Effect.runPromise(program);
74
+ expect(result).toBe('');
75
+ });
76
+ });
77
+
78
+ describe('writeFileEnsuringDir', () => {
79
+ it('writes content and creates parent directories', async () => {
80
+ const files: Record<string, string> = {};
81
+ const program = writeFileEnsuringDir('/a/b/c.txt', 'content').pipe(
82
+ Effect.provide(makeFsLayer(files)),
83
+ Effect.provide(Path.layer),
84
+ );
85
+ await Effect.runPromise(program);
86
+ expect(files['/a/b/c.txt']).toBe('content');
87
+ });
88
+
89
+ it('overwrites existing file', async () => {
90
+ const files: Record<string, string> = { '/existing.txt': 'old' };
91
+ const program = writeFileEnsuringDir('/existing.txt', 'new').pipe(
92
+ Effect.provide(makeFsLayer(files)),
93
+ Effect.provide(Path.layer),
94
+ );
95
+ await Effect.runPromise(program);
96
+ expect(files['/existing.txt']).toBe('new');
97
+ });
98
+
99
+ it('handles deeply nested paths', async () => {
100
+ const files: Record<string, string> = {};
101
+ const program = writeFileEnsuringDir('/a/b/c/d/e.txt', 'nested').pipe(
102
+ Effect.provide(makeFsLayer(files)),
103
+ Effect.provide(Path.layer),
104
+ );
105
+ await Effect.runPromise(program);
106
+ expect(files['/a/b/c/d/e.txt']).toBe('nested');
107
+ });
108
+
109
+ it('handles empty content', async () => {
110
+ const files: Record<string, string> = {};
111
+ const program = writeFileEnsuringDir('/empty.txt', '').pipe(
112
+ Effect.provide(makeFsLayer(files)),
113
+ Effect.provide(Path.layer),
114
+ );
115
+ await Effect.runPromise(program);
116
+ expect(files['/empty.txt']).toBe('');
117
+ });
118
+
119
+ it('handles unicode content', async () => {
120
+ const files: Record<string, string> = {};
121
+ const content = 'Héllo 🌍 — 日本語';
122
+ const program = writeFileEnsuringDir('/unicode.txt', content).pipe(
123
+ Effect.provide(makeFsLayer(files)),
124
+ Effect.provide(Path.layer),
125
+ );
126
+ await Effect.runPromise(program);
127
+ expect(files['/unicode.txt']).toBe(content);
128
+ });
129
+
130
+ it('handles paths with spaces', async () => {
131
+ const files: Record<string, string> = {};
132
+ const program = writeFileEnsuringDir('/path with spaces/file.txt', 'content').pipe(
133
+ Effect.provide(makeFsLayer(files)),
134
+ Effect.provide(Path.layer),
135
+ );
136
+ await Effect.runPromise(program);
137
+ expect(files['/path with spaces/file.txt']).toBe('content');
138
+ });
139
+ });
@@ -0,0 +1,21 @@
1
+ import { FileSystem, Path } from '@effect/platform';
2
+ import { Effect } from 'effect';
3
+
4
+ export function readFileIfExists(path: string) {
5
+ return Effect.gen(function* () {
6
+ const fs = yield* FileSystem.FileSystem;
7
+ const exists = yield* fs.exists(path);
8
+ if (!exists) return null;
9
+ return yield* fs.readFileString(path);
10
+ });
11
+ }
12
+
13
+ export function writeFileEnsuringDir(path: string, content: string) {
14
+ return Effect.gen(function* () {
15
+ const fs = yield* FileSystem.FileSystem;
16
+ const path_ = yield* Path.Path;
17
+ const dir = path_.dirname(path);
18
+ yield* fs.makeDirectory(dir, { recursive: true });
19
+ yield* fs.writeFileString(path, content);
20
+ });
21
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Effect } from 'effect';
3
+ import { FileSystem } from '@effect/platform/FileSystem';
4
+ import { Path } from '@effect/platform/Path';
5
+ import { CommandExecutor } from '@effect/platform/CommandExecutor';
6
+ import { NodePlatformLayer } from './node-layer.js';
7
+
8
+ describe('NodePlatformLayer', () => {
9
+ it('provides a working FileSystem service', async () => {
10
+ const program = Effect.gen(function* () {
11
+ const fs = yield* FileSystem;
12
+ return yield* fs.exists(process.cwd());
13
+ });
14
+ const result = await Effect.runPromise(Effect.provide(program, NodePlatformLayer));
15
+ expect(result).toBe(true);
16
+ });
17
+
18
+ it('provides a working Path service', async () => {
19
+ const program = Effect.gen(function* () {
20
+ const path = yield* Path;
21
+ return path.join('a', 'b');
22
+ });
23
+ const result = await Effect.runPromise(Effect.provide(program, NodePlatformLayer));
24
+ expect(result).toContain('a');
25
+ expect(result).toContain('b');
26
+ });
27
+
28
+ it('provides a working CommandExecutor service', async () => {
29
+ const program = Effect.gen(function* () {
30
+ const executor = yield* CommandExecutor;
31
+ return executor !== undefined;
32
+ });
33
+ const result = await Effect.runPromise(Effect.provide(program, NodePlatformLayer));
34
+ expect(result).toBe(true);
35
+ });
36
+
37
+ it('can read a real file', async () => {
38
+ const program = Effect.gen(function* () {
39
+ const fs = yield* FileSystem;
40
+ return yield* fs.readFileString(process.cwd() + '/package.json');
41
+ });
42
+ const result = await Effect.runPromise(Effect.provide(program, NodePlatformLayer));
43
+ expect(result).toContain('name');
44
+ });
45
+
46
+ it('reports missing files correctly', async () => {
47
+ const program = Effect.gen(function* () {
48
+ const fs = yield* FileSystem;
49
+ return yield* fs.exists('/nonexistent/path/that/should/not/exist');
50
+ });
51
+ const result = await Effect.runPromise(Effect.provide(program, NodePlatformLayer));
52
+ expect(result).toBe(false);
53
+ });
54
+ });
@@ -0,0 +1,10 @@
1
+ import { NodeContext } from '@effect/platform-node';
2
+ import { Layer } from 'effect';
3
+
4
+ /**
5
+ * The only file in this package (besides cli/main.ts) permitted to know
6
+ * this is running on Node.js. Provides FileSystem, Path, and
7
+ * CommandExecutor. Swapping to Bun/Deno later means swapping this one
8
+ * import for @effect/platform-bun's equivalent — nothing else changes.
9
+ */
10
+ export const NodePlatformLayer = NodeContext.layer;
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Effect, Either } from 'effect';
3
+ import { CommandExecutor, Command } from '@effect/platform';
4
+ import { NodePlatformLayer } from './node-layer.js';
5
+ import { readPhpArrayAsJson, PhpExecutionError } from './php-array-reader.js';
6
+ import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { execSync } from 'node:child_process';
10
+
11
+ function hasPhpBinary(): boolean {
12
+ try {
13
+ execSync('php -v', { stdio: 'ignore' });
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ describe('readPhpArrayAsJson', () => {
21
+ const testDir = join(tmpdir(), `dialekt-php-test-${Date.now()}`);
22
+
23
+ it.skipIf(!hasPhpBinary())('reads a PHP array file as JSON', async () => {
24
+ mkdirSync(testDir, { recursive: true });
25
+ const filePath = join(testDir, 'test.php');
26
+ writeFileSync(filePath, "<?php return ['email' => 'Email address'];");
27
+
28
+ const program = Effect.provide(readPhpArrayAsJson(filePath), NodePlatformLayer);
29
+ const result = await Effect.runPromise(program);
30
+ expect(result).toEqual({ email: 'Email address' });
31
+
32
+ rmSync(testDir, { recursive: true, force: true });
33
+ });
34
+
35
+ it.skipIf(!hasPhpBinary())('returns PhpExecutionError for a nonexistent file', async () => {
36
+ const program = Effect.provide(readPhpArrayAsJson('/nonexistent/file.php'), NodePlatformLayer);
37
+ const exit = await Effect.runPromise(Effect.either(program)) as Either.Either<unknown, PhpExecutionError>;
38
+ if (exit._tag === 'Left') {
39
+ expect(exit.left._tag).toBe('PhpExecutionError');
40
+ expect(exit.left.path).toBe('/nonexistent/file.php');
41
+ } else {
42
+ throw new Error('Expected Left');
43
+ }
44
+ });
45
+
46
+ it.skipIf(!hasPhpBinary())('reads nested PHP arrays', async () => {
47
+ mkdirSync(testDir, { recursive: true });
48
+ const filePath = join(testDir, 'nested.php');
49
+ writeFileSync(filePath, "<?php return ['validation' => ['email' => 'Email address', 'required' => 'Required field']];");
50
+
51
+ const program = Effect.provide(readPhpArrayAsJson(filePath), NodePlatformLayer);
52
+ const result = await Effect.runPromise(program);
53
+ expect(result).toEqual({
54
+ validation: { email: 'Email address', required: 'Required field' },
55
+ });
56
+
57
+ rmSync(testDir, { recursive: true, force: true });
58
+ });
59
+
60
+ it.skipIf(!hasPhpBinary())('reads PHP arrays with numeric keys as JS arrays', async () => {
61
+ mkdirSync(testDir, { recursive: true });
62
+ const filePath = join(testDir, 'numeric.php');
63
+ writeFileSync(filePath, "<?php return ['first', 'second', 'third'];");
64
+
65
+ const program = Effect.provide(readPhpArrayAsJson(filePath), NodePlatformLayer);
66
+ const result = await Effect.runPromise(program);
67
+ expect(result).toEqual(['first', 'second', 'third']);
68
+
69
+ rmSync(testDir, { recursive: true, force: true });
70
+ });
71
+
72
+ it.skipIf(!hasPhpBinary())('reads PHP arrays with unicode values', async () => {
73
+ mkdirSync(testDir, { recursive: true });
74
+ const filePath = join(testDir, 'unicode.php');
75
+ writeFileSync(filePath, "<?php return ['greeting' => 'Héllo 🌍 — 日本語'];");
76
+
77
+ const program = Effect.provide(readPhpArrayAsJson(filePath), NodePlatformLayer);
78
+ const result = await Effect.runPromise(program);
79
+ expect(result).toEqual({ greeting: 'Héllo 🌍 — 日本語' });
80
+
81
+ rmSync(testDir, { recursive: true, force: true });
82
+ });
83
+
84
+ it.skipIf(!hasPhpBinary())('reads empty PHP array as empty JS array', async () => {
85
+ mkdirSync(testDir, { recursive: true });
86
+ const filePath = join(testDir, 'empty.php');
87
+ writeFileSync(filePath, "<?php return [];");
88
+
89
+ const program = Effect.provide(readPhpArrayAsJson(filePath), NodePlatformLayer);
90
+ const result = await Effect.runPromise(program);
91
+ expect(result).toEqual([]);
92
+
93
+ rmSync(testDir, { recursive: true, force: true });
94
+ });
95
+
96
+ it.skipIf(!hasPhpBinary())('returns PhpExecutionError for malformed PHP', async () => {
97
+ mkdirSync(testDir, { recursive: true });
98
+ const filePath = join(testDir, 'bad.php');
99
+ writeFileSync(filePath, "<?php this is not valid php");
100
+
101
+ const program = Effect.provide(readPhpArrayAsJson(filePath), NodePlatformLayer);
102
+ const exit = await Effect.runPromise(Effect.either(program)) as Either.Either<unknown, PhpExecutionError>;
103
+ expect(exit._tag).toBe('Left');
104
+ if (exit._tag === 'Left') {
105
+ expect(exit.left._tag).toBe('PhpExecutionError');
106
+ }
107
+
108
+ rmSync(testDir, { recursive: true, force: true });
109
+ });
110
+
111
+ it('is skipped when php is unavailable', () => {
112
+ expect(hasPhpBinary()).toBe(true); // meta-test: if php IS available, this proves skipIf works
113
+ });
114
+ });
@@ -0,0 +1,26 @@
1
+ import { Command } from '@effect/platform';
2
+ import type { CommandExecutor } from '@effect/platform/CommandExecutor';
3
+ import { Effect, Data } from 'effect';
4
+
5
+ export class PhpExecutionError extends Data.TaggedError('PhpExecutionError')<{
6
+ readonly path: string;
7
+ readonly cause: unknown;
8
+ }> {}
9
+
10
+ const DUMP_SCRIPT =
11
+ "echo json_encode(is_array($v = require $argv[1]) ? $v : [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);";
12
+
13
+ export function readPhpArrayAsJson(
14
+ absolutePath: string,
15
+ ): Effect.Effect<Record<string, unknown>, PhpExecutionError, CommandExecutor> {
16
+ return Effect.gen(function* () {
17
+ const cmd = Command.make('php', '-r', DUMP_SCRIPT, '--', absolutePath);
18
+ const output = yield* Command.string(cmd).pipe(
19
+ Effect.mapError((cause) => new PhpExecutionError({ path: absolutePath, cause })),
20
+ );
21
+ return yield* Effect.try({
22
+ try: () => JSON.parse(output) as Record<string, unknown>,
23
+ catch: (cause) => new PhpExecutionError({ path: absolutePath, cause }),
24
+ });
25
+ });
26
+ }