@o3co/ts.hocon 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 (97) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  3. package/.github/pull_request_template.md +17 -0
  4. package/.github/workflows/lint.yml +33 -0
  5. package/.github/workflows/release.yml +41 -0
  6. package/.github/workflows/test.yml +53 -0
  7. package/CHANGELOG.md +22 -0
  8. package/CONTRIBUTING.md +62 -0
  9. package/LICENSE +202 -0
  10. package/README.ja.md +195 -0
  11. package/README.md +195 -0
  12. package/SECURITY.md +20 -0
  13. package/docs/superpowers/plans/2026-03-20-ts-hocon.md +2412 -0
  14. package/docs/superpowers/specs/2026-03-20-ts-hocon-design.md +368 -0
  15. package/package.json +44 -0
  16. package/src/config.ts +108 -0
  17. package/src/errors.ts +34 -0
  18. package/src/index.ts +5 -0
  19. package/src/internal/lexer/lexer.ts +168 -0
  20. package/src/internal/lexer/token.ts +20 -0
  21. package/src/internal/parser/ast.ts +17 -0
  22. package/src/internal/parser/parser.ts +229 -0
  23. package/src/internal/resolver/resolver.ts +428 -0
  24. package/src/parse.ts +79 -0
  25. package/src/value.ts +5 -0
  26. package/src/zod.ts +12 -0
  27. package/tests/config.test.ts +100 -0
  28. package/tests/errors.test.ts +43 -0
  29. package/tests/lexer.test.ts +141 -0
  30. package/tests/lightbend/lightbend.test.ts +39 -0
  31. package/tests/lightbend/testdata/a_1.conf +1 -0
  32. package/tests/lightbend/testdata/b_2.conf +1 -0
  33. package/tests/lightbend/testdata/beanconfig/beanconfig01.conf +131 -0
  34. package/tests/lightbend/testdata/bom.conf +2 -0
  35. package/tests/lightbend/testdata/cycle.conf +1 -0
  36. package/tests/lightbend/testdata/env-variables.conf +16 -0
  37. package/tests/lightbend/testdata/equiv01/comments.conf +45 -0
  38. package/tests/lightbend/testdata/equiv01/equals.conf +41 -0
  39. package/tests/lightbend/testdata/equiv01/no-commas.conf +55 -0
  40. package/tests/lightbend/testdata/equiv01/no-root-braces.conf +40 -0
  41. package/tests/lightbend/testdata/equiv01/no-whitespace.json +1 -0
  42. package/tests/lightbend/testdata/equiv01/omit-colons.conf +44 -0
  43. package/tests/lightbend/testdata/equiv01/original.json +41 -0
  44. package/tests/lightbend/testdata/equiv01/path-keys.conf +29 -0
  45. package/tests/lightbend/testdata/equiv01/properties-style.conf +37 -0
  46. package/tests/lightbend/testdata/equiv01/substitutions.conf +41 -0
  47. package/tests/lightbend/testdata/equiv01/unquoted.conf +41 -0
  48. package/tests/lightbend/testdata/equiv02/original.json +17 -0
  49. package/tests/lightbend/testdata/equiv02/path-keys-weird-whitespace.conf +32 -0
  50. package/tests/lightbend/testdata/equiv02/path-keys.conf +9 -0
  51. package/tests/lightbend/testdata/equiv03/includes.conf +7 -0
  52. package/tests/lightbend/testdata/equiv03/letters/a.conf +6 -0
  53. package/tests/lightbend/testdata/equiv03/letters/b.json +3 -0
  54. package/tests/lightbend/testdata/equiv03/letters/c.conf +2 -0
  55. package/tests/lightbend/testdata/equiv03/letters/c.properties +1 -0
  56. package/tests/lightbend/testdata/equiv03/letters/numbers/1.conf +1 -0
  57. package/tests/lightbend/testdata/equiv03/letters/numbers/2.properties +1 -0
  58. package/tests/lightbend/testdata/equiv03/original.json +15 -0
  59. package/tests/lightbend/testdata/equiv03/root/foo.conf +1 -0
  60. package/tests/lightbend/testdata/equiv04/missing-substitutions.conf +4 -0
  61. package/tests/lightbend/testdata/equiv04/original.json +3 -0
  62. package/tests/lightbend/testdata/equiv05/original.json +16 -0
  63. package/tests/lightbend/testdata/equiv05/triple-quotes.conf +20 -0
  64. package/tests/lightbend/testdata/file-include.conf +5 -0
  65. package/tests/lightbend/testdata/include-from-list.conf +5 -0
  66. package/tests/lightbend/testdata/onclasspath.conf +2 -0
  67. package/tests/lightbend/testdata/subdir/bar-file.conf +1 -0
  68. package/tests/lightbend/testdata/subdir/bar.conf +1 -0
  69. package/tests/lightbend/testdata/subdir/baz.conf +1 -0
  70. package/tests/lightbend/testdata/subdir/foo.conf +5 -0
  71. package/tests/lightbend/testdata/test01.conf +96 -0
  72. package/tests/lightbend/testdata/test01.json +4 -0
  73. package/tests/lightbend/testdata/test01.properties +5 -0
  74. package/tests/lightbend/testdata/test02.conf +13 -0
  75. package/tests/lightbend/testdata/test03-included.conf +9 -0
  76. package/tests/lightbend/testdata/test03.conf +36 -0
  77. package/tests/lightbend/testdata/test04.conf +305 -0
  78. package/tests/lightbend/testdata/test05.conf +154 -0
  79. package/tests/lightbend/testdata/test06.conf +14 -0
  80. package/tests/lightbend/testdata/test07.conf +4 -0
  81. package/tests/lightbend/testdata/test08.conf +6 -0
  82. package/tests/lightbend/testdata/test09.conf +17 -0
  83. package/tests/lightbend/testdata/test10.conf +11 -0
  84. package/tests/lightbend/testdata/test11.conf +7 -0
  85. package/tests/lightbend/testdata/test12.conf +43 -0
  86. package/tests/lightbend/testdata/test13-application-override-substitutions.conf +1 -0
  87. package/tests/lightbend/testdata/test13-reference-bad-substitutions.conf +1 -0
  88. package/tests/lightbend/testdata/test13-reference-with-substitutions.conf +2 -0
  89. package/tests/lightbend/testdata/validate-invalid.conf +30 -0
  90. package/tests/lightbend/testdata/validate-reference.conf +32 -0
  91. package/tests/parse.test.ts +44 -0
  92. package/tests/parser.test.ts +154 -0
  93. package/tests/resolver.test.ts +159 -0
  94. package/tests/zod.test.ts +57 -0
  95. package/tsconfig.json +16 -0
  96. package/tsup.config.ts +12 -0
  97. package/vitest.config.ts +7 -0
@@ -0,0 +1,2412 @@
1
+ # @o3co/ts.hocon Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** TypeScript 製 HOCON パーサーライブラリ `@o3co/ts.hocon` を実装する。
6
+
7
+ **Architecture:** Lexer → Parser → Resolver(2パス) → Config API のパイプライン。内部値は discriminated union + Map ベース。外部 API は TypeScript イディオムに沿った関数 + Config クラス。Zod はオプション peer dependency として `src/zod.ts` に分離。
8
+
9
+ **Tech Stack:** TypeScript 5, Vitest 3, tsup 8, Node.js (`fs`), Zod 3(optional peer dep)
10
+
11
+ **Reference:** Go 実装 `/Volumes/Workspace/o3co/repos/go.hocon` — ロジックの参考にすること。Lightbend テストデータ `/Volumes/Workspace/o3co/repos/go.hocon/testdata/hocon/` を流用する。
12
+
13
+ ---
14
+
15
+ ## File Map
16
+
17
+ ```text
18
+ src/
19
+ ├── index.ts # public exports
20
+ ├── parse.ts # parse / parseAsync / parseFile / parseFileAsync
21
+ ├── config.ts # Config クラス
22
+ ├── value.ts # HoconValue 型定義
23
+ ├── errors.ts # ParseError / ResolveError / ConfigError
24
+ ├── zod.ts # Zod インテグレーション(別エントリポイント)
25
+ └── internal/
26
+ ├── lexer/
27
+ │ ├── token.ts # Token 型定義
28
+ │ └── lexer.ts # tokenize() 関数
29
+ ├── parser/
30
+ │ ├── ast.ts # AstNode 型定義
31
+ │ └── parser.ts # parseTokens() 関数
32
+ └── resolver/
33
+ └── resolver.ts # Resolver クラス(2パス)
34
+
35
+ tests/
36
+ ├── errors.test.ts
37
+ ├── lexer.test.ts
38
+ ├── parser.test.ts
39
+ ├── resolver.test.ts
40
+ ├── config.test.ts
41
+ ├── parse.test.ts # end-to-end 統合テスト
42
+ ├── zod.test.ts
43
+ └── lightbend/
44
+ ├── *.conf / *.json # Lightbend 公式テストスイート(go.hocon からコピー)
45
+ └── lightbend.test.ts
46
+
47
+ package.json
48
+ tsconfig.json
49
+ vitest.config.ts
50
+ tsup.config.ts
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Task 1: プロジェクト初期化
56
+
57
+ **Files:**
58
+ - Create: `package.json`
59
+ - Create: `tsconfig.json`
60
+ - Create: `vitest.config.ts`
61
+ - Create: `tsup.config.ts`
62
+ - Create: `src/index.ts`
63
+
64
+ - [ ] **Step 1: npm init & 依存関係インストール**
65
+
66
+ ```bash
67
+ cd /Volumes/Workspace/o3co/repos/ts.hocon
68
+ npm init -y
69
+ npm install -D typescript@^5 vitest@^3 tsup@^8 @types/node
70
+ ```
71
+
72
+ - [ ] **Step 2: `package.json` のスクリプト・exports・peerDeps を設定**
73
+
74
+ ```json
75
+ {
76
+ "name": "@o3co/ts.hocon",
77
+ "version": "0.1.0",
78
+ "type": "module",
79
+ "exports": {
80
+ ".": {
81
+ "import": "./dist/index.js",
82
+ "require": "./dist/index.cjs",
83
+ "types": "./dist/index.d.ts"
84
+ },
85
+ "./zod": {
86
+ "import": "./dist/zod.js",
87
+ "require": "./dist/zod.cjs",
88
+ "types": "./dist/zod.d.ts"
89
+ }
90
+ },
91
+ "main": "./dist/index.cjs",
92
+ "module": "./dist/index.js",
93
+ "types": "./dist/index.d.ts",
94
+ "scripts": {
95
+ "build": "tsup",
96
+ "test": "vitest run",
97
+ "test:watch": "vitest",
98
+ "typecheck": "tsc --noEmit"
99
+ },
100
+ "peerDependencies": {
101
+ "zod": ">=3.0.0"
102
+ },
103
+ "peerDependenciesMeta": {
104
+ "zod": { "optional": true }
105
+ }
106
+ }
107
+ ```
108
+
109
+ - [ ] **Step 3: `tsconfig.json` を作成**
110
+
111
+ ```json
112
+ {
113
+ "compilerOptions": {
114
+ "target": "ES2020",
115
+ "lib": ["ES2020"],
116
+ "module": "NodeNext",
117
+ "moduleResolution": "NodeNext",
118
+ "strict": true,
119
+ "declaration": true,
120
+ "declarationMap": true,
121
+ "outDir": "dist",
122
+ "rootDir": "src",
123
+ "skipLibCheck": true
124
+ },
125
+ "include": ["src"],
126
+ "exclude": ["node_modules", "dist"]
127
+ }
128
+ ```
129
+
130
+ - [ ] **Step 4: `vitest.config.ts` を作成**
131
+
132
+ ```ts
133
+ import { defineConfig } from 'vitest/config'
134
+
135
+ export default defineConfig({
136
+ test: {
137
+ include: ['tests/**/*.test.ts'],
138
+ },
139
+ })
140
+ ```
141
+
142
+ - [ ] **Step 5: `tsup.config.ts` を作成**
143
+
144
+ ```ts
145
+ import { defineConfig } from 'tsup'
146
+
147
+ export default defineConfig({
148
+ entry: {
149
+ index: 'src/index.ts',
150
+ zod: 'src/zod.ts',
151
+ },
152
+ format: ['esm', 'cjs'],
153
+ dts: true,
154
+ clean: true,
155
+ splitting: false,
156
+ })
157
+ ```
158
+
159
+ - [ ] **Step 6: `.gitignore` を作成**
160
+
161
+ ```text
162
+ node_modules/
163
+ dist/
164
+ *.tsbuildinfo
165
+ ```
166
+
167
+ - [ ] **Step 7: `src/index.ts` を作成(空のプレースホルダー)**
168
+
169
+ ```ts
170
+ // exports will be added in later tasks
171
+ export {}
172
+ ```
173
+
174
+ - [ ] **Step 8: `tsc --noEmit` が通ることを確認**
175
+
176
+ ```bash
177
+ npx tsc --noEmit
178
+ ```
179
+
180
+ Expected: エラーなし
181
+
182
+ - [ ] **Step 9: commit**
183
+
184
+ ```bash
185
+ git init
186
+ git add .gitignore package.json tsconfig.json vitest.config.ts tsup.config.ts src/index.ts
187
+ git commit -m "feat: initialize @o3co/ts.hocon project scaffold"
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Task 2: エラー型
193
+
194
+ **Files:**
195
+ - Create: `src/errors.ts`
196
+ - Create: `tests/errors.test.ts`
197
+
198
+ - [ ] **Step 1: テストを書く**
199
+
200
+ ```ts
201
+ // tests/errors.test.ts
202
+ import { describe, it, expect } from 'vitest'
203
+ import { ParseError, ResolveError, ConfigError } from '../src/errors.js'
204
+
205
+ describe('ParseError', () => {
206
+ it('is an Error with line/col', () => {
207
+ const e = new ParseError('unexpected token', 3, 10)
208
+ expect(e).toBeInstanceOf(Error)
209
+ expect(e.message).toBe('unexpected token')
210
+ expect(e.line).toBe(3)
211
+ expect(e.col).toBe(10)
212
+ expect(e.file).toBeUndefined()
213
+ })
214
+
215
+ it('accepts optional file path', () => {
216
+ const e = new ParseError('bad syntax', 1, 1, 'app.conf')
217
+ expect(e.file).toBe('app.conf')
218
+ })
219
+ })
220
+
221
+ describe('ResolveError', () => {
222
+ it('carries path and position', () => {
223
+ const e = new ResolveError('unresolved substitution', 'server.host', 5, 3)
224
+ expect(e).toBeInstanceOf(Error)
225
+ expect(e.path).toBe('server.host')
226
+ expect(e.line).toBe(5)
227
+ expect(e.col).toBe(3)
228
+ })
229
+
230
+ it('line/col can be 0 (no position info)', () => {
231
+ const e = new ResolveError('include failed', 'path', 0, 0, 'app.conf')
232
+ expect(e.line).toBe(0)
233
+ expect(e.col).toBe(0)
234
+ expect(e.file).toBe('app.conf')
235
+ })
236
+ })
237
+
238
+ describe('ConfigError', () => {
239
+ it('carries path', () => {
240
+ const e = new ConfigError('key not found', 'server.port')
241
+ expect(e).toBeInstanceOf(Error)
242
+ expect(e.path).toBe('server.port')
243
+ })
244
+ })
245
+ ```
246
+
247
+ - [ ] **Step 2: テストを実行(失敗を確認)**
248
+
249
+ ```bash
250
+ npx vitest run tests/errors.test.ts
251
+ ```
252
+
253
+ Expected: FAIL(`../src/errors.js` not found)
254
+
255
+ - [ ] **Step 3: `src/errors.ts` を実装**
256
+
257
+ ```ts
258
+ export class ParseError extends Error {
259
+ constructor(
260
+ message: string,
261
+ public readonly line: number,
262
+ public readonly col: number,
263
+ public readonly file?: string,
264
+ ) {
265
+ super(message)
266
+ this.name = 'ParseError'
267
+ }
268
+ }
269
+
270
+ export class ResolveError extends Error {
271
+ constructor(
272
+ message: string,
273
+ public readonly path: string,
274
+ public readonly line: number,
275
+ public readonly col: number,
276
+ public readonly file?: string,
277
+ ) {
278
+ super(message)
279
+ this.name = 'ResolveError'
280
+ }
281
+ }
282
+
283
+ export class ConfigError extends Error {
284
+ constructor(
285
+ message: string,
286
+ public readonly path: string,
287
+ ) {
288
+ super(message)
289
+ this.name = 'ConfigError'
290
+ }
291
+ }
292
+ ```
293
+
294
+ - [ ] **Step 4: テストを実行(pass を確認)**
295
+
296
+ ```bash
297
+ npx vitest run tests/errors.test.ts
298
+ ```
299
+
300
+ Expected: 5 tests pass
301
+
302
+ - [ ] **Step 5: commit**
303
+
304
+ ```bash
305
+ git add src/errors.ts tests/errors.test.ts
306
+ git commit -m "feat: add error types (ParseError, ResolveError, ConfigError)"
307
+ ```
308
+
309
+ ---
310
+
311
+ ## Task 3: Lexer
312
+
313
+ **Files:**
314
+ - Create: `src/internal/lexer/token.ts`
315
+ - Create: `src/internal/lexer/lexer.ts`
316
+ - Create: `tests/lexer.test.ts`
317
+
318
+ - [ ] **Step 1: `src/internal/lexer/token.ts` を作成(型定義のみ)**
319
+
320
+ ```ts
321
+ export type TokenKind =
322
+ | 'lbrace' | 'rbrace'
323
+ | 'lbracket' | 'rbracket'
324
+ | 'comma' | 'colon' | 'equals' | 'plus_equals'
325
+ | 'newline'
326
+ | 'string' // "..." quoted string
327
+ | 'triple_string' // """..."""
328
+ | 'unquoted' // bare word, number, true/false/null
329
+ | 'subst' // ${path}
330
+ | 'opt_subst' // ${?path}
331
+ | 'eof'
332
+
333
+ export type Token = {
334
+ kind: TokenKind
335
+ value: string // always string; parser converts to number/bool/null
336
+ line: number
337
+ col: number
338
+ isQuoted: boolean // true for "..." and """..."""
339
+ precedingSpace: boolean // true if preceded by whitespace (concat detection)
340
+ }
341
+ ```
342
+
343
+ - [ ] **Step 2: テストを書く**
344
+
345
+ ```ts
346
+ // tests/lexer.test.ts
347
+ import { describe, it, expect } from 'vitest'
348
+ import { tokenize } from '../src/internal/lexer/lexer.js'
349
+ import { ParseError } from '../src/errors.js'
350
+
351
+ describe('tokenize', () => {
352
+ it('tokenizes empty string', () => {
353
+ const tokens = tokenize('')
354
+ expect(tokens).toHaveLength(1)
355
+ expect(tokens[0].kind).toBe('eof')
356
+ })
357
+
358
+ it('tokenizes braces and brackets', () => {
359
+ const tokens = tokenize('{}[]')
360
+ expect(tokens.map(t => t.kind)).toEqual(['lbrace', 'rbrace', 'lbracket', 'rbracket', 'eof'])
361
+ })
362
+
363
+ it('tokenizes = and +=', () => {
364
+ const tokens = tokenize('=+=')
365
+ expect(tokens[0].kind).toBe('equals')
366
+ expect(tokens[1].kind).toBe('plus_equals')
367
+ })
368
+
369
+ it('tokenizes : and ,', () => {
370
+ const tokens = tokenize(':,')
371
+ expect(tokens.map(t => t.kind)).toEqual(['colon', 'comma', 'eof'])
372
+ })
373
+
374
+ it('skips // comments, keeps newline', () => {
375
+ const tokens = tokenize('// comment\nfoo')
376
+ expect(tokens[0].kind).toBe('newline')
377
+ expect(tokens[1].kind).toBe('unquoted')
378
+ expect(tokens[1].value).toBe('foo')
379
+ })
380
+
381
+ it('skips # comments', () => {
382
+ const tokens = tokenize('# comment\nfoo')
383
+ expect(tokens[0].kind).toBe('newline')
384
+ expect(tokens[1].value).toBe('foo')
385
+ })
386
+
387
+ it('tokenizes quoted strings', () => {
388
+ const [t] = tokenize('"hello world"')
389
+ expect(t.kind).toBe('string')
390
+ expect(t.value).toBe('hello world')
391
+ expect(t.isQuoted).toBe(true)
392
+ })
393
+
394
+ it('handles escape sequences', () => {
395
+ const [t] = tokenize('"a\\nb\\tc"')
396
+ expect(t.value).toBe('a\nb\tc')
397
+ })
398
+
399
+ it('handles \\u unicode escapes', () => {
400
+ const [t] = tokenize('"\\u0041"')
401
+ expect(t.value).toBe('A')
402
+ })
403
+
404
+ it('tokenizes triple-quoted strings', () => {
405
+ const [t] = tokenize('"""hello\nworld"""')
406
+ expect(t.kind).toBe('triple_string')
407
+ expect(t.value).toBe('hello\nworld')
408
+ expect(t.isQuoted).toBe(true)
409
+ })
410
+
411
+ it('strips leading newline from triple-quoted strings', () => {
412
+ const [t] = tokenize('"""\nhello"""')
413
+ expect(t.value).toBe('hello')
414
+ })
415
+
416
+ it('tokenizes unquoted strings', () => {
417
+ const [t] = tokenize('localhost')
418
+ expect(t.kind).toBe('unquoted')
419
+ expect(t.value).toBe('localhost')
420
+ expect(t.isQuoted).toBe(false)
421
+ })
422
+
423
+ it('tokenizes numbers as unquoted', () => {
424
+ const [t] = tokenize('8080')
425
+ expect(t.kind).toBe('unquoted')
426
+ expect(t.value).toBe('8080')
427
+ })
428
+
429
+ it('tokenizes substitutions', () => {
430
+ const [t] = tokenize('${server.host}')
431
+ expect(t.kind).toBe('subst')
432
+ expect(t.value).toBe('server.host')
433
+ })
434
+
435
+ it('tokenizes optional substitutions', () => {
436
+ const [t] = tokenize('${?foo}')
437
+ expect(t.kind).toBe('opt_subst')
438
+ expect(t.value).toBe('foo')
439
+ })
440
+
441
+ it('tokenizes newlines', () => {
442
+ const tokens = tokenize('a\nb')
443
+ expect(tokens[1].kind).toBe('newline')
444
+ })
445
+
446
+ it('deduplicates consecutive newlines', () => {
447
+ const tokens = tokenize('a\n\n\nb')
448
+ const newlines = tokens.filter(t => t.kind === 'newline')
449
+ expect(newlines).toHaveLength(1)
450
+ })
451
+
452
+ it('tracks line and col', () => {
453
+ const tokens = tokenize('a\nb')
454
+ expect(tokens[0].line).toBe(1)
455
+ expect(tokens[0].col).toBe(1)
456
+ expect(tokens[2].line).toBe(2)
457
+ expect(tokens[2].col).toBe(1)
458
+ })
459
+
460
+ it('sets precedingSpace on token after whitespace', () => {
461
+ const tokens = tokenize('a b')
462
+ expect(tokens[1].precedingSpace).toBe(true)
463
+ expect(tokens[0].precedingSpace).toBe(false)
464
+ })
465
+
466
+ it('strips UTF-8 BOM', () => {
467
+ const tokens = tokenize('\uFEFFfoo')
468
+ expect(tokens[0].value).toBe('foo')
469
+ })
470
+
471
+ it('stops unquoted scan at $ for concat', () => {
472
+ const tokens = tokenize('foo${bar}')
473
+ expect(tokens[0].kind).toBe('unquoted')
474
+ expect(tokens[0].value).toBe('foo')
475
+ expect(tokens[1].kind).toBe('subst')
476
+ expect(tokens[1].value).toBe('bar')
477
+ expect(tokens[1].precedingSpace).toBe(false)
478
+ })
479
+
480
+ it('throws ParseError on unterminated string', () => {
481
+ expect(() => tokenize('"unterminated')).toThrow(ParseError)
482
+ })
483
+
484
+ it('throws ParseError on unterminated substitution', () => {
485
+ expect(() => tokenize('${foo')).toThrow(ParseError)
486
+ })
487
+ })
488
+ ```
489
+
490
+ - [ ] **Step 3: テストを実行(失敗を確認)**
491
+
492
+ ```bash
493
+ npx vitest run tests/lexer.test.ts
494
+ ```
495
+
496
+ Expected: FAIL(lexer.ts not found)
497
+
498
+ - [ ] **Step 4: `src/internal/lexer/lexer.ts` を実装**
499
+
500
+ ```ts
501
+ import { ParseError } from '../../errors.js'
502
+ import type { Token, TokenKind } from './token.js'
503
+
504
+ export function tokenize(input: string): Token[] {
505
+ if (input.charCodeAt(0) === 0xfeff) input = input.slice(1)
506
+
507
+ const tokens: Token[] = []
508
+ let pos = 0
509
+ let line = 1
510
+ let col = 1
511
+ let hadSpace = false
512
+
513
+ function peek(offset = 0): string { return input[pos + offset] ?? '' }
514
+
515
+ function advance(): string {
516
+ const ch = input[pos++]!
517
+ if (ch === '\n') { line++; col = 1 } else { col++ }
518
+ return ch
519
+ }
520
+
521
+ function push(kind: TokenKind, value: string, l: number, c: number, isQuoted = false): void {
522
+ tokens.push({ kind, value, line: l, col: c, isQuoted, precedingSpace: hadSpace })
523
+ hadSpace = false
524
+ }
525
+
526
+ while (pos < input.length) {
527
+ const sl = line, sc = col
528
+ const ch = peek()
529
+
530
+ // Whitespace (not newline)
531
+ if (ch === ' ' || ch === '\t' || ch === '\r') {
532
+ advance(); hadSpace = true; continue
533
+ }
534
+
535
+ // Newline
536
+ if (ch === '\n') {
537
+ advance()
538
+ if (tokens.length === 0 || tokens[tokens.length - 1]!.kind !== 'newline') {
539
+ push('newline', '\n', sl, sc)
540
+ }
541
+ continue
542
+ }
543
+
544
+ // Comments
545
+ if (ch === '/' && peek(1) === '/') {
546
+ while (pos < input.length && peek() !== '\n') advance()
547
+ hadSpace = true; continue
548
+ }
549
+ if (ch === '#') {
550
+ while (pos < input.length && peek() !== '\n') advance()
551
+ hadSpace = true; continue
552
+ }
553
+
554
+ // Single-char punctuation
555
+ const single: Record<string, TokenKind> = { '{': 'lbrace', '}': 'rbrace', '[': 'lbracket', ']': 'rbracket', ',': 'comma', ':': 'colon' }
556
+ if (ch in single) { advance(); push(single[ch]!, ch, sl, sc); continue }
557
+
558
+ // = and +=
559
+ if (ch === '=') { advance(); push('equals', '=', sl, sc); continue }
560
+ if (ch === '+' && peek(1) === '=') { advance(); advance(); push('plus_equals', '+=', sl, sc); continue }
561
+
562
+ // Substitution ${...} or ${?...}
563
+ if (ch === '$' && peek(1) === '{') {
564
+ advance(); advance()
565
+ const optional = peek() === '?'
566
+ if (optional) advance()
567
+ let path = ''
568
+ while (pos < input.length && peek() !== '}') {
569
+ if (peek() === '\n') throw new ParseError('unterminated substitution', sl, sc)
570
+ path += advance()
571
+ }
572
+ if (peek() !== '}') throw new ParseError('unterminated substitution', sl, sc)
573
+ advance()
574
+ push(optional ? 'opt_subst' : 'subst', path.trim(), sl, sc)
575
+ continue
576
+ }
577
+
578
+ // Triple-quoted string
579
+ if (ch === '"' && peek(1) === '"' && peek(2) === '"') {
580
+ advance(); advance(); advance()
581
+ let value = ''
582
+ while (!(peek() === '"' && peek(1) === '"' && peek(2) === '"')) {
583
+ if (pos >= input.length) throw new ParseError('unterminated triple-quoted string', sl, sc)
584
+ value += advance()
585
+ }
586
+ advance(); advance(); advance()
587
+ if (value.startsWith('\n')) value = value.slice(1)
588
+ push('triple_string', value, sl, sc, true)
589
+ continue
590
+ }
591
+
592
+ // Quoted string
593
+ if (ch === '"') {
594
+ advance()
595
+ let value = ''
596
+ while (pos < input.length && peek() !== '"') {
597
+ if (peek() === '\n') throw new ParseError('unterminated string', sl, sc)
598
+ if (peek() === '\\') {
599
+ advance()
600
+ const esc = advance()
601
+ switch (esc) {
602
+ case 'n': value += '\n'; break
603
+ case 't': value += '\t'; break
604
+ case 'r': value += '\r'; break
605
+ case '"': value += '"'; break
606
+ case '\\': value += '\\'; break
607
+ case '/': value += '/'; break
608
+ case 'b': value += '\b'; break
609
+ case 'f': value += '\f'; break
610
+ case 'u': {
611
+ const hex = input.slice(pos, pos + 4)
612
+ value += String.fromCharCode(parseInt(hex, 16))
613
+ for (let i = 0; i < 4; i++) advance()
614
+ break
615
+ }
616
+ default: value += esc
617
+ }
618
+ } else {
619
+ value += advance()
620
+ }
621
+ }
622
+ if (peek() !== '"') throw new ParseError('unterminated string', sl, sc)
623
+ advance()
624
+ push('string', value, sl, sc, true)
625
+ continue
626
+ }
627
+
628
+ // Unquoted string (stops at terminators and $)
629
+ if (isUnquotedStart(ch)) {
630
+ let value = ''
631
+ while (pos < input.length && isUnquotedContinue(peek(), () => peek(1))) {
632
+ value += advance()
633
+ }
634
+ push('unquoted', value.trimEnd(), sl, sc)
635
+ continue
636
+ }
637
+
638
+ throw new ParseError(`unexpected character: ${JSON.stringify(ch)}`, sl, sc)
639
+ }
640
+
641
+ tokens.push({ kind: 'eof', value: '', line, col, isQuoted: false, precedingSpace: false })
642
+ return tokens
643
+ }
644
+
645
+ function isUnquotedStart(ch: string): boolean {
646
+ return ch !== '' && !'{}[],:=+#\n\r\t "$'.includes(ch)
647
+ }
648
+
649
+ function isUnquotedContinue(ch: string, nextFn: () => string): boolean {
650
+ if (ch === '' || '{}[],:=\n#"$'.includes(ch)) return false
651
+ if (ch === '+' && nextFn() === '=') return false
652
+ if (ch === '/' && nextFn() === '/') return false
653
+ return true
654
+ }
655
+ ```
656
+
657
+ - [ ] **Step 5: テストを実行(pass を確認)**
658
+
659
+ ```bash
660
+ npx vitest run tests/lexer.test.ts
661
+ ```
662
+
663
+ Expected: 全テスト pass
664
+
665
+ - [ ] **Step 6: commit**
666
+
667
+ ```bash
668
+ git add src/internal/lexer/ tests/lexer.test.ts
669
+ git commit -m "feat: implement HOCON lexer (tokenize)"
670
+ ```
671
+
672
+ ---
673
+
674
+ ## Task 4: Parser
675
+
676
+ **Files:**
677
+ - Create: `src/internal/parser/ast.ts`
678
+ - Create: `src/internal/parser/parser.ts`
679
+ - Create: `tests/parser.test.ts`
680
+
681
+ - [ ] **Step 1: `src/internal/parser/ast.ts` を作成(型定義のみ)**
682
+
683
+ ```ts
684
+ export type Pos = { line: number; col: number; file?: string }
685
+
686
+ export type AstNode =
687
+ | { kind: 'object'; fields: AstField[]; pos: Pos }
688
+ | { kind: 'array'; items: AstNode[]; pos: Pos }
689
+ | { kind: 'scalar'; value: string | number | boolean | null; pos: Pos }
690
+ | { kind: 'concat'; nodes: AstNode[]; pos: Pos }
691
+ | { kind: 'subst'; path: string; optional: boolean; pos: Pos }
692
+ | { kind: 'include'; path: string; pos: Pos }
693
+
694
+ // key が空配列のとき include ディレクティブを表す(value は include ノード)
695
+ export type AstField = {
696
+ key: string[] // 各要素は ドット分割済み。quoted キーはドット分割しない
697
+ value: AstNode
698
+ append: boolean // true なら += 演算子
699
+ pos: Pos
700
+ }
701
+ ```
702
+
703
+ - [ ] **Step 2: テストを書く**
704
+
705
+ ```ts
706
+ // tests/parser.test.ts
707
+ import { describe, it, expect } from 'vitest'
708
+ import { tokenize } from '../src/internal/lexer/lexer.js'
709
+ import { parseTokens } from '../src/internal/parser/parser.js'
710
+ import type { AstNode } from '../src/internal/parser/ast.js'
711
+
712
+ function parse(input: string): AstNode {
713
+ return parseTokens(tokenize(input))
714
+ }
715
+
716
+ describe('parseTokens', () => {
717
+ it('parses empty input as empty object', () => {
718
+ const node = parse('')
719
+ expect(node.kind).toBe('object')
720
+ if (node.kind === 'object') expect(node.fields).toHaveLength(0)
721
+ })
722
+
723
+ it('parses key = value', () => {
724
+ const node = parse('host = "localhost"')
725
+ expect(node.kind).toBe('object')
726
+ if (node.kind === 'object') {
727
+ expect(node.fields[0]!.key).toEqual(['host'])
728
+ expect(node.fields[0]!.value.kind).toBe('scalar')
729
+ }
730
+ })
731
+
732
+ it('parses key: value (colon syntax)', () => {
733
+ const node = parse('port: 8080')
734
+ if (node.kind === 'object') {
735
+ expect(node.fields[0]!.key).toEqual(['port'])
736
+ }
737
+ })
738
+
739
+ it('parses dot-notation keys', () => {
740
+ const node = parse('server.host = "localhost"')
741
+ if (node.kind === 'object') {
742
+ expect(node.fields[0]!.key).toEqual(['server', 'host'])
743
+ }
744
+ })
745
+
746
+ it('does not split quoted keys at dots', () => {
747
+ const node = parse('"a.b" = 1')
748
+ if (node.kind === 'object') {
749
+ expect(node.fields[0]!.key).toEqual(['a.b'])
750
+ }
751
+ })
752
+
753
+ it('parses nested objects (brace syntax)', () => {
754
+ const node = parse('server { host = "localhost" }')
755
+ expect(node.kind).toBe('object')
756
+ if (node.kind === 'object') {
757
+ expect(node.fields[0]!.key).toEqual(['server'])
758
+ expect(node.fields[0]!.value.kind).toBe('object')
759
+ }
760
+ })
761
+
762
+ it('parses arrays', () => {
763
+ const node = parse('list = [1, 2, 3]')
764
+ if (node.kind === 'object') {
765
+ const arr = node.fields[0]!.value
766
+ expect(arr.kind).toBe('array')
767
+ if (arr.kind === 'array') expect(arr.items).toHaveLength(3)
768
+ }
769
+ })
770
+
771
+ it('parses boolean and null scalars', () => {
772
+ const node = parse('a = true\nb = false\nc = null')
773
+ if (node.kind === 'object') {
774
+ const vals = node.fields.map(f => f.value)
775
+ expect(vals[0]!.kind === 'scalar' && vals[0]!.value).toBe(true)
776
+ expect(vals[1]!.kind === 'scalar' && vals[1]!.value).toBe(false)
777
+ expect(vals[2]!.kind === 'scalar' && vals[2]!.value).toBe(null)
778
+ }
779
+ })
780
+
781
+ it('parses integer scalars', () => {
782
+ const node = parse('port = 8080')
783
+ if (node.kind === 'object') {
784
+ const val = node.fields[0]!.value
785
+ expect(val.kind === 'scalar' && val.value).toBe(8080)
786
+ }
787
+ })
788
+
789
+ it('parses float scalars', () => {
790
+ const node = parse('ratio = 1.5')
791
+ if (node.kind === 'object') {
792
+ const val = node.fields[0]!.value
793
+ expect(val.kind === 'scalar' && val.value).toBe(1.5)
794
+ }
795
+ })
796
+
797
+ it('parses substitutions', () => {
798
+ const node = parse('host = ${server.host}')
799
+ if (node.kind === 'object') {
800
+ const val = node.fields[0]!.value
801
+ expect(val.kind).toBe('subst')
802
+ if (val.kind === 'subst') {
803
+ expect(val.path).toBe('server.host')
804
+ expect(val.optional).toBe(false)
805
+ }
806
+ }
807
+ })
808
+
809
+ it('parses optional substitutions', () => {
810
+ const node = parse('host = ${?server.host}')
811
+ if (node.kind === 'object') {
812
+ const val = node.fields[0]!.value
813
+ if (val.kind === 'subst') expect(val.optional).toBe(true)
814
+ }
815
+ })
816
+
817
+ it('parses concat (adjacent tokens on same line, no space)', () => {
818
+ const node = parse('url = "http://"${host}":8080"')
819
+ if (node.kind === 'object') {
820
+ expect(node.fields[0]!.value.kind).toBe('concat')
821
+ }
822
+ })
823
+
824
+ it('parses += append operator', () => {
825
+ const node = parse('list += 1')
826
+ if (node.kind === 'object') {
827
+ expect(node.fields[0]!.append).toBe(true)
828
+ }
829
+ })
830
+
831
+ it('parses include directive', () => {
832
+ const node = parse('include "other.conf"')
833
+ if (node.kind === 'object') {
834
+ expect(node.fields[0]!.key).toHaveLength(0)
835
+ expect(node.fields[0]!.value.kind).toBe('include')
836
+ }
837
+ })
838
+
839
+ it('parses include file(...) syntax', () => {
840
+ const node = parse('include file("other.conf")')
841
+ if (node.kind === 'object') {
842
+ expect(node.fields[0]!.value.kind).toBe('include')
843
+ }
844
+ })
845
+ })
846
+ ```
847
+
848
+ - [ ] **Step 3: テストを実行(失敗を確認)**
849
+
850
+ ```bash
851
+ npx vitest run tests/parser.test.ts
852
+ ```
853
+
854
+ Expected: FAIL(parser.ts not found)
855
+
856
+ - [ ] **Step 4: `src/internal/parser/parser.ts` を実装**
857
+
858
+ Parser の責務:
859
+ 1. Token 列を受け取り、ルートが `AstNode` の `object` である AST を返す
860
+ 2. ルートオブジェクトの外括弧は省略可(bare オブジェクト)
861
+ 3. concat 検出:同じ行内で直前に空白がなく隣接する値 → `concat` ノード
862
+ 4. `true`/`false`/`null` のキーワード変換、数値変換
863
+
864
+ ```ts
865
+ import { ParseError } from '../../errors.js'
866
+ import type { Token } from '../lexer/token.js'
867
+ import type { AstNode, AstField, Pos } from './ast.js'
868
+
869
+ export function parseTokens(tokens: Token[]): AstNode {
870
+ let pos = 0
871
+
872
+ function peek(): Token { return tokens[pos] ?? { kind: 'eof', value: '', line: 0, col: 0, isQuoted: false, precedingSpace: false } }
873
+ function advance(): Token { return tokens[pos++] ?? peek() }
874
+ function skip(...kinds: string[]): void {
875
+ while (kinds.includes(peek().kind)) advance()
876
+ }
877
+
878
+ function currentPos(): Pos {
879
+ const t = peek()
880
+ return { line: t.line, col: t.col }
881
+ }
882
+
883
+ function parseObject(expectClosingBrace: boolean): AstNode {
884
+ const p = currentPos()
885
+ const fields: AstField[] = []
886
+
887
+ while (true) {
888
+ skip('newline')
889
+ const t = peek()
890
+ if (t.kind === 'eof' || t.kind === 'rbrace') break
891
+
892
+ // include directive
893
+ if (t.kind === 'unquoted' && t.value === 'include') {
894
+ advance()
895
+ fields.push(parseInclude())
896
+ continue
897
+ }
898
+
899
+ // key
900
+ const keyPos = currentPos()
901
+ const key = parseKey()
902
+
903
+ // value separator (optional)
904
+ skip('newline')
905
+ let append = false
906
+ const sep = peek()
907
+ if (sep.kind === 'equals') advance()
908
+ else if (sep.kind === 'plus_equals') { advance(); append = true }
909
+ else if (sep.kind === 'colon') advance()
910
+ else if (sep.kind === 'lbrace') { /* key { ... } shorthand, no advance */ }
911
+ else if (sep.kind !== 'newline' && sep.kind !== 'eof') {
912
+ throw new ParseError(`unexpected token after key: ${sep.kind}`, sep.line, sep.col)
913
+ }
914
+
915
+ skip('newline')
916
+ const value = parseValue()
917
+ fields.push({ key, value, append, pos: keyPos })
918
+
919
+ // trailing separator
920
+ skip('newline')
921
+ if (peek().kind === 'comma') advance()
922
+ skip('newline')
923
+ }
924
+
925
+ if (expectClosingBrace) {
926
+ const t = peek()
927
+ if (t.kind !== 'rbrace') throw new ParseError('expected }', t.line, t.col)
928
+ advance()
929
+ }
930
+
931
+ return { kind: 'object', fields, pos: p }
932
+ }
933
+
934
+ function parseKey(): string[] {
935
+ const segments: string[] = []
936
+ while (true) {
937
+ const t = peek()
938
+ if (t.kind === 'string') {
939
+ advance()
940
+ segments.push(t.value) // quoted: no dot split
941
+ } else if (t.kind === 'unquoted') {
942
+ advance()
943
+ // Split unquoted key at dots
944
+ segments.push(...t.value.split('.').filter(s => s.length > 0))
945
+ } else {
946
+ throw new ParseError(`expected key, got ${t.kind}`, t.line, t.col)
947
+ }
948
+ // dot-chained: "a"."b" or a.b (already split above for unquoted)
949
+ // For quoted segments chained with dots we need to check next token
950
+ if (peek().kind === 'unquoted' || peek().kind === 'string') {
951
+ // could be key value without separator — stop if no dot following
952
+ // Heuristic: if next is a quoted string after no precedingSpace — continue key
953
+ // Actually for simplicity, stop here; dot-chain within unquoted already handled by split
954
+ break
955
+ }
956
+ break
957
+ }
958
+ return segments
959
+ }
960
+
961
+ function parseInclude(): AstField {
962
+ const p = currentPos()
963
+ const t = peek()
964
+ let path: string
965
+
966
+ if (t.kind === 'string') {
967
+ path = advance().value
968
+ } else if (t.kind === 'unquoted' && t.value === 'file') {
969
+ advance()
970
+ const lp = peek()
971
+ if (lp.kind !== 'unquoted' || lp.value !== '(') {
972
+ // Actually: after `file` we expect `(` — but since `(` is unquoted, let's re-check
973
+ // The lexer produces `file` as unquoted and `(` as part of unquoted or a separate token
974
+ // Safer: just scan until we find the quoted path inside
975
+ }
976
+ // Consume `file("path")`
977
+ // Skip `(` — it may be tokenized as part of unquoted or separate
978
+ // Consume everything until we hit a quoted string
979
+ while (peek().kind !== 'string' && peek().kind !== 'eof') advance()
980
+ path = advance().value
981
+ // Consume closing `)`
982
+ while (peek().kind !== 'newline' && peek().kind !== 'rbrace' && peek().kind !== 'eof') advance()
983
+ } else {
984
+ throw new ParseError(`expected include path, got ${t.kind}`, t.line, t.col)
985
+ }
986
+
987
+ return {
988
+ key: [],
989
+ value: { kind: 'include', path, pos: p },
990
+ append: false,
991
+ pos: p,
992
+ }
993
+ }
994
+
995
+ function parseValue(): AstNode {
996
+ const p = currentPos()
997
+ const parts: AstNode[] = []
998
+
999
+ while (true) {
1000
+ const t = peek()
1001
+ if (t.kind === 'eof' || t.kind === 'newline' || t.kind === 'rbrace' || t.kind === 'rbracket' || t.kind === 'comma') break
1002
+ if (parts.length > 0 && t.precedingSpace) break // space separates values in value position
1003
+
1004
+ let node: AstNode
1005
+ if (t.kind === 'lbrace') {
1006
+ advance()
1007
+ node = parseObject(true)
1008
+ } else if (t.kind === 'lbracket') {
1009
+ advance()
1010
+ node = parseArray()
1011
+ } else if (t.kind === 'subst' || t.kind === 'opt_subst') {
1012
+ advance()
1013
+ node = { kind: 'subst', path: t.value, optional: t.kind === 'opt_subst', pos: { line: t.line, col: t.col } }
1014
+ } else if (t.kind === 'string' || t.kind === 'triple_string') {
1015
+ advance()
1016
+ node = { kind: 'scalar', value: t.value, pos: { line: t.line, col: t.col } }
1017
+ } else if (t.kind === 'unquoted') {
1018
+ advance()
1019
+ node = { kind: 'scalar', value: parseScalarValue(t.value), pos: { line: t.line, col: t.col } }
1020
+ } else {
1021
+ break
1022
+ }
1023
+ parts.push(node)
1024
+ }
1025
+
1026
+ if (parts.length === 0) throw new ParseError('expected value', peek().line, peek().col)
1027
+ if (parts.length === 1) return parts[0]!
1028
+ return { kind: 'concat', nodes: parts, pos: p }
1029
+ }
1030
+
1031
+ function parseArray(): AstNode {
1032
+ const p = currentPos()
1033
+ const items: AstNode[] = []
1034
+
1035
+ while (true) {
1036
+ skip('newline')
1037
+ if (peek().kind === 'rbracket' || peek().kind === 'eof') break
1038
+ items.push(parseValue())
1039
+ skip('newline')
1040
+ if (peek().kind === 'comma') advance()
1041
+ skip('newline')
1042
+ }
1043
+
1044
+ const t = peek()
1045
+ if (t.kind !== 'rbracket') throw new ParseError('expected ]', t.line, t.col)
1046
+ advance()
1047
+ return { kind: 'array', items, pos: p }
1048
+ }
1049
+
1050
+ function parseScalarValue(raw: string): string | number | boolean | null {
1051
+ if (raw === 'true') return true
1052
+ if (raw === 'false') return false
1053
+ if (raw === 'null') return null
1054
+ const n = Number(raw)
1055
+ if (!isNaN(n) && raw.trim() !== '') return n
1056
+ return raw
1057
+ }
1058
+
1059
+ skip('newline')
1060
+ const t = peek()
1061
+ if (t.kind === 'lbrace') {
1062
+ advance()
1063
+ return parseObject(true)
1064
+ }
1065
+ return parseObject(false)
1066
+ }
1067
+ ```
1068
+
1069
+ **注意:** この実装は基本ケースをカバーするもの。Go 実装 (`/Volumes/Workspace/o3co/repos/go.hocon/internal/parser/parser.go`) を参照しながら、エッジケース(dot-chain quoted keys、value concatenation のルール等)を補完すること。
1070
+
1071
+ - [ ] **Step 5: テストを実行(pass を確認)**
1072
+
1073
+ ```bash
1074
+ npx vitest run tests/parser.test.ts
1075
+ ```
1076
+
1077
+ Expected: 全テスト pass
1078
+
1079
+ - [ ] **Step 6: commit**
1080
+
1081
+ ```bash
1082
+ git add src/internal/parser/ tests/parser.test.ts
1083
+ git commit -m "feat: implement HOCON AST parser"
1084
+ ```
1085
+
1086
+ ---
1087
+
1088
+ ## Task 5: HoconValue 型 + Resolver(完全実装、include なし)
1089
+
1090
+ **Files:**
1091
+ - Create: `src/value.ts`
1092
+ - Create: `src/internal/resolver/resolver.ts`
1093
+ - Create: `tests/resolver.test.ts`
1094
+
1095
+ - [ ] **Step 1: `src/value.ts` を作成(型定義のみ)**
1096
+
1097
+ ```ts
1098
+ // JavaScript の Map は挿入順を保証するため keys フィールド不要
1099
+ export type HoconValue =
1100
+ | { kind: 'object'; fields: Map<string, HoconValue> }
1101
+ | { kind: 'array'; items: HoconValue[] }
1102
+ | { kind: 'scalar'; value: string | number | boolean | null }
1103
+ ```
1104
+
1105
+ - [ ] **Step 2: Resolver (Pass 1) のテストを書く**
1106
+
1107
+ ```ts
1108
+ // tests/resolver.test.ts
1109
+ import { describe, it, expect } from 'vitest'
1110
+ import { tokenize } from '../src/internal/lexer/lexer.js'
1111
+ import { parseTokens } from '../src/internal/parser/parser.js'
1112
+ import { resolve } from '../src/internal/resolver/resolver.js'
1113
+ import type { HoconValue } from '../src/value.js'
1114
+ import { ResolveError } from '../src/errors.js'
1115
+
1116
+ function resolveStr(input: string, env: Record<string, string> = {}): HoconValue {
1117
+ const ast = parseTokens(tokenize(input))
1118
+ return resolve(ast, { env, baseDir: undefined, readFileSync: () => { throw new Error('no fs') } })
1119
+ }
1120
+
1121
+ function obj(v: HoconValue): Map<string, HoconValue> {
1122
+ if (v.kind !== 'object') throw new Error('expected object')
1123
+ return v.fields
1124
+ }
1125
+
1126
+ describe('Resolver - Pass 1 (structure)', () => {
1127
+ it('resolves simple string value', () => {
1128
+ const v = resolveStr('host = "localhost"')
1129
+ expect(obj(v).get('host')).toEqual({ kind: 'scalar', value: 'localhost' })
1130
+ })
1131
+
1132
+ it('resolves number value', () => {
1133
+ const v = resolveStr('port = 8080')
1134
+ expect(obj(v).get('port')).toEqual({ kind: 'scalar', value: 8080 })
1135
+ })
1136
+
1137
+ it('resolves nested objects', () => {
1138
+ const v = resolveStr('server { host = "localhost" }')
1139
+ const server = obj(v).get('server')
1140
+ expect(server?.kind).toBe('object')
1141
+ })
1142
+
1143
+ it('merges duplicate object keys', () => {
1144
+ const v = resolveStr('server { host = "a" }\nserver { port = 8080 }')
1145
+ const server = obj(v).get('server')
1146
+ if (server?.kind === 'object') {
1147
+ expect(server.fields.has('host')).toBe(true)
1148
+ expect(server.fields.has('port')).toBe(true)
1149
+ }
1150
+ })
1151
+
1152
+ it('last value wins for scalar keys', () => {
1153
+ const v = resolveStr('x = 1\nx = 2')
1154
+ expect(obj(v).get('x')).toEqual({ kind: 'scalar', value: 2 })
1155
+ })
1156
+
1157
+ it('resolves arrays', () => {
1158
+ const v = resolveStr('list = [1, 2, 3]')
1159
+ const arr = obj(v).get('list')
1160
+ expect(arr?.kind).toBe('array')
1161
+ if (arr?.kind === 'array') expect(arr.items).toHaveLength(3)
1162
+ })
1163
+
1164
+ it('handles += on existing array', () => {
1165
+ const v = resolveStr('list = [1, 2]\nlist += 3')
1166
+ const arr = obj(v).get('list')
1167
+ if (arr?.kind === 'array') expect(arr.items).toHaveLength(3)
1168
+ })
1169
+
1170
+ it('handles += on missing key (starts from empty array)', () => {
1171
+ const v = resolveStr('list += 1')
1172
+ const arr = obj(v).get('list')
1173
+ if (arr?.kind === 'array') expect(arr.items).toHaveLength(1)
1174
+ })
1175
+
1176
+ it('preserves Map insertion order', () => {
1177
+ const v = resolveStr('c = 3\na = 1\nb = 2')
1178
+ const keys = [...obj(v).keys()]
1179
+ expect(keys).toEqual(['c', 'a', 'b'])
1180
+ })
1181
+ })
1182
+
1183
+ describe('Resolver - Pass 2 (substitutions)', () => {
1184
+ it('resolves ${path} substitution', () => {
1185
+ const v = resolveStr('host = "localhost"\nurl = ${host}')
1186
+ expect(obj(v).get('url')).toEqual({ kind: 'scalar', value: 'localhost' })
1187
+ })
1188
+
1189
+ it('resolves nested path substitution', () => {
1190
+ const v = resolveStr('server { host = "x" }\nhost = ${server.host}')
1191
+ expect(obj(v).get('host')).toEqual({ kind: 'scalar', value: 'x' })
1192
+ })
1193
+
1194
+ it('resolves ${?path} optional substitution (path exists)', () => {
1195
+ const v = resolveStr('a = 1\nb = ${?a}')
1196
+ expect(obj(v).get('b')).toEqual({ kind: 'scalar', value: 1 })
1197
+ })
1198
+
1199
+ it('drops field for ${?path} optional substitution (path missing)', () => {
1200
+ const v = resolveStr('b = ${?missing}')
1201
+ expect(obj(v).get('b')).toBeUndefined()
1202
+ })
1203
+
1204
+ it('throws ResolveError for unresolved mandatory substitution', () => {
1205
+ expect(() => resolveStr('b = ${missing}')).toThrow(ResolveError)
1206
+ })
1207
+
1208
+ it('falls back to env for unresolved substitution', () => {
1209
+ const v = resolveStr('b = ${MY_VAR}', { MY_VAR: 'hello' })
1210
+ expect(obj(v).get('b')).toEqual({ kind: 'scalar', value: 'hello' })
1211
+ })
1212
+
1213
+ it('resolves self-referential substitution', () => {
1214
+ const v = resolveStr('path = "/usr"\npath = ${path}:/extra')
1215
+ // concat resolves: "/usr" + ":/extra" = "/usr:/extra"
1216
+ const result = obj(v).get('path')
1217
+ if (result?.kind === 'scalar') expect(String(result.value)).toContain('/usr')
1218
+ })
1219
+
1220
+ it('resolves string concat with substitution', () => {
1221
+ const v = resolveStr('host = "localhost"\nurl = "http://"${host}')
1222
+ const result = obj(v).get('url')
1223
+ expect(result?.kind === 'scalar' && result.value).toBe('http://localhost')
1224
+ })
1225
+
1226
+ it('throws ResolveError on circular substitution', () => {
1227
+ expect(() => resolveStr('a = ${b}\nb = ${a}')).toThrow(ResolveError)
1228
+ })
1229
+ })
1230
+ ```
1231
+
1232
+ - [ ] **Step 3: テストを実行(失敗を確認)**
1233
+
1234
+ ```bash
1235
+ npx vitest run tests/resolver.test.ts
1236
+ ```
1237
+
1238
+ Expected: FAIL(resolver.ts not found)
1239
+
1240
+ - [ ] **Step 4: `src/internal/resolver/resolver.ts` を実装(Pass 1 + Pass 2、include なし)**
1241
+
1242
+ Go 版 `/Volumes/Workspace/o3co/repos/go.hocon/internal/resolver/resolver.go` を参照すること。
1243
+
1244
+ ```ts
1245
+ import { ResolveError } from '../../errors.js'
1246
+ import type { AstNode, AstField } from '../parser/ast.js'
1247
+ import type { HoconValue } from '../../../value.js'
1248
+
1249
+ // ---- Internal placeholder types (not exported) ----
1250
+ // _kind strings match the spec exactly
1251
+ type SubstPlaceholder = {
1252
+ _kind: 'subst-placeholder'
1253
+ path: string
1254
+ optional: boolean
1255
+ line: number
1256
+ col: number
1257
+ }
1258
+ type ConcatPlaceholder = {
1259
+ _kind: 'concat-placeholder'
1260
+ nodes: ResolverValue[]
1261
+ }
1262
+ // AppendPlaceholder for += operator
1263
+ type AppendPlaceholder = {
1264
+ _kind: 'append-placeholder'
1265
+ existing: ResolverValue
1266
+ elem: ResolverValue
1267
+ }
1268
+
1269
+ // ResObj: internal object node (discriminated by _kind so TypeScript can narrow it)
1270
+ type ResObj = {
1271
+ _kind: 'res-obj'
1272
+ fields: Map<string, ResolverValue>
1273
+ priorValues: Map<string, ResolverValue>
1274
+ }
1275
+
1276
+ type ResolverValue = HoconValue | SubstPlaceholder | ConcatPlaceholder | AppendPlaceholder | ResObj
1277
+
1278
+ function isSubst(v: ResolverValue): v is SubstPlaceholder {
1279
+ return (v as SubstPlaceholder)._kind === 'subst-placeholder'
1280
+ }
1281
+ function isConcat(v: ResolverValue): v is ConcatPlaceholder {
1282
+ return (v as ConcatPlaceholder)._kind === 'concat-placeholder'
1283
+ }
1284
+ function isAppend(v: ResolverValue): v is AppendPlaceholder {
1285
+ return (v as AppendPlaceholder)._kind === 'append-placeholder'
1286
+ }
1287
+ function isResObj(v: ResolverValue): v is ResObj {
1288
+ return (v as ResObj)._kind === 'res-obj'
1289
+ }
1290
+
1291
+ function makeResObj(): ResObj {
1292
+ return { _kind: 'res-obj', fields: new Map(), priorValues: new Map() }
1293
+ }
1294
+
1295
+ export type ResolveOptions = {
1296
+ env: Record<string, string>
1297
+ baseDir: string | undefined
1298
+ readFileSync: (filePath: string) => string
1299
+ includeStack?: string[]
1300
+ }
1301
+
1302
+ // ---- Public entry point ----
1303
+
1304
+ export function resolve(ast: AstNode, opts: ResolveOptions): HoconValue {
1305
+ // Pass 1
1306
+ const root = buildResObj(ast, opts)
1307
+ // Pass 2
1308
+ const resolving = new Set<string>()
1309
+ const resolvedCache = new Map<string, HoconValue>()
1310
+ return resolveResObj(root, root, resolving, resolvedCache, opts)
1311
+ }
1312
+
1313
+ // ---- Pass 1: structure building ----
1314
+
1315
+ function buildResObj(ast: AstNode, opts: ResolveOptions): ResObj {
1316
+ if (ast.kind !== 'object') {
1317
+ throw new ResolveError('root AST must be an object', '', ast.pos.line, ast.pos.col)
1318
+ }
1319
+ const obj = makeResObj()
1320
+ for (const field of ast.fields) {
1321
+ applyField(obj, field, opts)
1322
+ }
1323
+ return obj
1324
+ }
1325
+
1326
+ function applyField(obj: ResObj, field: AstField, opts: ResolveOptions): void {
1327
+ // include directive: key is empty, value is include node
1328
+ if (field.key.length === 0 && field.value.kind === 'include') {
1329
+ const included = loadInclude(field.value.path, opts)
1330
+ deepMergeResObjInto(obj, included)
1331
+ return
1332
+ }
1333
+
1334
+ const [head, ...tail] = field.key
1335
+ if (!head) return
1336
+
1337
+ if (tail.length > 0) {
1338
+ // Nested key: server.host = "x" → create synthetic object AST
1339
+ const syntheticAst: AstNode = {
1340
+ kind: 'object',
1341
+ fields: [{ key: tail, value: field.value, append: field.append, pos: field.pos }],
1342
+ pos: field.pos,
1343
+ }
1344
+ applyField(obj, { key: [head], value: syntheticAst, append: false, pos: field.pos }, opts)
1345
+ return
1346
+ }
1347
+
1348
+ if (field.append) {
1349
+ // +=: append elem to existing array (or start from [])
1350
+ const existing: ResolverValue = obj.fields.get(head) ?? ({ kind: 'array', items: [] } satisfies HoconValue)
1351
+ obj.priorValues.set(head, existing)
1352
+ obj.fields.set(head, {
1353
+ _kind: 'append-placeholder',
1354
+ existing,
1355
+ elem: astToResolverValue(field.value),
1356
+ })
1357
+ return
1358
+ }
1359
+
1360
+ // Normal assignment
1361
+ const existing = obj.fields.get(head)
1362
+ const newVal = astToResolverValue(field.value)
1363
+
1364
+ // Save prior value for self-referential substitution resolution
1365
+ if (existing !== undefined) {
1366
+ obj.priorValues.set(head, existing)
1367
+ }
1368
+
1369
+ // Deep merge if both are ResObj
1370
+ if (existing !== undefined && isResObj(existing) && isResObj(newVal)) {
1371
+ deepMergeResObjInto(existing, newVal)
1372
+ // existing already in fields — no re-set needed
1373
+ return
1374
+ }
1375
+
1376
+ obj.fields.set(head, newVal)
1377
+ }
1378
+
1379
+ function astToResolverValue(ast: AstNode): ResolverValue {
1380
+ switch (ast.kind) {
1381
+ case 'scalar':
1382
+ return { kind: 'scalar', value: ast.value }
1383
+ case 'array':
1384
+ return { kind: 'array', items: ast.items.map(i => astToResolverValue(i) as HoconValue) }
1385
+ case 'object': {
1386
+ // Nested object literal: build a ResObj for Pass 2 processing
1387
+ // opts are not available here, but includes inside object literals are rare;
1388
+ // pass a no-op readFileSync — include in nested objects will throw at resolve time
1389
+ const inner = buildResObj(ast, { env: {}, baseDir: undefined, readFileSync: () => { throw new ResolveError('include in nested object literal not supported here', '', 0, 0) } })
1390
+ return inner
1391
+ }
1392
+ case 'subst':
1393
+ return { _kind: 'subst-placeholder', path: ast.path, optional: ast.optional, line: ast.pos.line, col: ast.pos.col }
1394
+ case 'concat':
1395
+ return { _kind: 'concat-placeholder', nodes: ast.nodes.map(astToResolverValue) }
1396
+ case 'include':
1397
+ return { kind: 'scalar', value: null } // handled by applyField; should not reach here
1398
+ }
1399
+ }
1400
+
1401
+ function deepMergeResObjInto(dst: ResObj, src: ResObj): void {
1402
+ for (const [k, srcVal] of src.fields) {
1403
+ const dstVal = dst.fields.get(k)
1404
+ if (dstVal !== undefined && isResObj(dstVal) && isResObj(srcVal)) {
1405
+ // Both are objects: recurse
1406
+ deepMergeResObjInto(dstVal, srcVal)
1407
+ } else {
1408
+ if (dstVal !== undefined) dst.priorValues.set(k, dstVal)
1409
+ dst.fields.set(k, srcVal)
1410
+ }
1411
+ }
1412
+ }
1413
+
1414
+ // ---- Pass 2: substitution resolution ----
1415
+
1416
+ function resolveResObj(
1417
+ obj: ResObj,
1418
+ root: ResObj,
1419
+ resolving: Set<string>,
1420
+ resolvedCache: Map<string, HoconValue>,
1421
+ opts: ResolveOptions,
1422
+ ): HoconValue {
1423
+ const result = new Map<string, HoconValue>()
1424
+ for (const [key, val] of obj.fields) {
1425
+ const resolved = resolveVal(val, obj, root, resolving, resolvedCache, opts)
1426
+ if (resolved !== undefined) result.set(key, resolved)
1427
+ }
1428
+ return { kind: 'object', fields: result }
1429
+ }
1430
+
1431
+ function resolveVal(
1432
+ v: ResolverValue,
1433
+ scope: ResObj,
1434
+ root: ResObj,
1435
+ resolving: Set<string>,
1436
+ resolvedCache: Map<string, HoconValue>,
1437
+ opts: ResolveOptions,
1438
+ ): HoconValue | undefined {
1439
+ if (isSubst(v)) return resolveSubst(v, scope, root, resolving, resolvedCache, opts)
1440
+ if (isConcat(v)) return resolveConcat(v.nodes, scope, root, resolving, resolvedCache, opts)
1441
+ if (isAppend(v)) return resolveAppend(v, scope, root, resolving, resolvedCache, opts)
1442
+ if (isResObj(v)) return resolveResObj(v, root, resolving, resolvedCache, opts)
1443
+ const hv = v as HoconValue
1444
+ if (hv.kind === 'array') {
1445
+ return {
1446
+ kind: 'array',
1447
+ items: hv.items.map(item =>
1448
+ resolveVal(item as ResolverValue, scope, root, resolving, resolvedCache, opts)
1449
+ ?? ({ kind: 'scalar', value: null } satisfies HoconValue)
1450
+ ),
1451
+ }
1452
+ }
1453
+ return hv
1454
+ }
1455
+
1456
+ function resolveSubst(
1457
+ s: SubstPlaceholder,
1458
+ scope: ResObj,
1459
+ root: ResObj,
1460
+ resolving: Set<string>,
1461
+ resolvedCache: Map<string, HoconValue>,
1462
+ opts: ResolveOptions,
1463
+ ): HoconValue | undefined {
1464
+ if (resolvedCache.has(s.path)) return resolvedCache.get(s.path)!
1465
+
1466
+ if (resolving.has(s.path)) {
1467
+ // Cycle detected: try prior value for self-referential substitutions
1468
+ const rootSeg = s.path.split('.')[0]!
1469
+ const prior = scope.priorValues.get(rootSeg)
1470
+ if (prior !== undefined) {
1471
+ return resolveVal(prior, scope, root, new Set(resolving), resolvedCache, opts)
1472
+ }
1473
+ throw new ResolveError(`circular substitution: ${s.path}`, s.path, s.line, s.col)
1474
+ }
1475
+
1476
+ resolving.add(s.path)
1477
+ try {
1478
+ const found = lookupPath(root, s.path.split('.'))
1479
+ if (found !== undefined) {
1480
+ const result = resolveVal(found, scope, root, resolving, resolvedCache, opts)
1481
+ if (result !== undefined) resolvedCache.set(s.path, result)
1482
+ return result
1483
+ }
1484
+
1485
+ // Env var fallback
1486
+ const envVal = opts.env[s.path]
1487
+ if (envVal !== undefined) {
1488
+ const result: HoconValue = { kind: 'scalar', value: envVal }
1489
+ resolvedCache.set(s.path, result)
1490
+ return result
1491
+ }
1492
+
1493
+ if (s.optional) return undefined
1494
+ throw new ResolveError(`could not resolve substitution: \${${s.path}}`, s.path, s.line, s.col)
1495
+ } finally {
1496
+ resolving.delete(s.path)
1497
+ }
1498
+ }
1499
+
1500
+ function resolveConcat(
1501
+ nodes: ResolverValue[],
1502
+ scope: ResObj,
1503
+ root: ResObj,
1504
+ resolving: Set<string>,
1505
+ resolvedCache: Map<string, HoconValue>,
1506
+ opts: ResolveOptions,
1507
+ ): HoconValue {
1508
+ const resolved = nodes
1509
+ .map(n => resolveVal(n, scope, root, resolving, resolvedCache, opts))
1510
+ .filter((v): v is HoconValue => v !== undefined)
1511
+
1512
+ if (resolved.length === 0) return { kind: 'scalar', value: null }
1513
+ if (resolved.length === 1) return resolved[0]!
1514
+
1515
+ // Array concatenation: if any element is an array, treat all as array elements
1516
+ if (resolved.some(v => v.kind === 'array')) {
1517
+ const items: HoconValue[] = []
1518
+ for (const v of resolved) {
1519
+ if (v.kind === 'array') items.push(...v.items)
1520
+ else items.push(v)
1521
+ }
1522
+ return { kind: 'array', items }
1523
+ }
1524
+
1525
+ // String concatenation
1526
+ const str = resolved.map(v => v.kind === 'scalar' ? String(v.value ?? '') : JSON.stringify(v)).join('')
1527
+ return { kind: 'scalar', value: str }
1528
+ }
1529
+
1530
+ function resolveAppend(
1531
+ a: AppendPlaceholder,
1532
+ scope: ResObj,
1533
+ root: ResObj,
1534
+ resolving: Set<string>,
1535
+ resolvedCache: Map<string, HoconValue>,
1536
+ opts: ResolveOptions,
1537
+ ): HoconValue {
1538
+ const existing = resolveVal(a.existing, scope, root, resolving, resolvedCache, opts)
1539
+ ?? ({ kind: 'array', items: [] } satisfies HoconValue)
1540
+ const elem = resolveVal(a.elem, scope, root, resolving, resolvedCache, opts)
1541
+ const items: HoconValue[] = existing.kind === 'array' ? [...existing.items] : [existing]
1542
+ if (elem !== undefined) items.push(elem)
1543
+ return { kind: 'array', items }
1544
+ }
1545
+
1546
+ function lookupPath(root: ResObj, segments: string[]): ResolverValue | undefined {
1547
+ const [head, ...tail] = segments
1548
+ if (!head) return undefined
1549
+ const val = root.fields.get(head)
1550
+ if (val === undefined) return undefined
1551
+ if (tail.length === 0) return val
1552
+ if (isResObj(val)) return lookupPath(val, tail)
1553
+ return undefined
1554
+ }
1555
+
1556
+ // loadInclude: stub — implemented in Task 6
1557
+ function loadInclude(includePath: string, opts: ResolveOptions): ResObj {
1558
+ throw new ResolveError(`include not yet supported: ${includePath}`, includePath, 0, 0)
1559
+ }
1560
+ ```
1561
+
1562
+ - [ ] **Step 5: テストを実行(pass を確認)**
1563
+
1564
+ ```bash
1565
+ npx vitest run tests/resolver.test.ts
1566
+ ```
1567
+
1568
+ Expected: 全テスト pass(include テストはまだないため全件 pass)
1569
+
1570
+ - [ ] **Step 6: commit**
1571
+
1572
+ ```bash
1573
+ git add src/value.ts src/internal/resolver/ tests/resolver.test.ts
1574
+ git commit -m "feat: implement HoconValue types and Resolver (Pass 1 + Pass 2, no include)"
1575
+ ```
1576
+
1577
+ ---
1578
+
1579
+ ## Task 6: Resolver — include ディレクティブ対応
1580
+
1581
+ **Files:**
1582
+
1583
+ - Modify: `src/internal/resolver/resolver.ts`(`loadInclude` を実装)
1584
+ - Modify: `tests/resolver.test.ts`(include テストを追加)
1585
+
1586
+ - [ ] **Step 1: include テストを `tests/resolver.test.ts` に追加**
1587
+
1588
+ ```ts
1589
+ // tests/resolver.test.ts の末尾に追加
1590
+
1591
+ describe('Resolver - include', () => {
1592
+ function resolveWithFs(input: string, files: Record<string, string>): HoconValue {
1593
+ const ast = parseTokens(tokenize(input))
1594
+ return resolve(ast, {
1595
+ env: {},
1596
+ baseDir: '/fake',
1597
+ readFileSync: (p: string) => {
1598
+ const key = p.replace('/fake/', '')
1599
+ if (!(key in files)) throw new Error(`file not found: ${p}`)
1600
+ return files[key]!
1601
+ },
1602
+ })
1603
+ }
1604
+
1605
+ it('merges included file into current object', () => {
1606
+ const v = resolveWithFs('include "other.conf"\nport = 8080', {
1607
+ 'other.conf': 'host = "localhost"',
1608
+ })
1609
+ expect(obj(v).get('host')).toEqual({ kind: 'scalar', value: 'localhost' })
1610
+ expect(obj(v).get('port')).toEqual({ kind: 'scalar', value: 8080 })
1611
+ })
1612
+
1613
+ it('throws ResolveError on circular include', () => {
1614
+ expect(() =>
1615
+ resolveWithFs('include "a.conf"', {
1616
+ 'a.conf': 'include "b.conf"',
1617
+ 'b.conf': 'include "a.conf"',
1618
+ })
1619
+ ).toThrow(ResolveError)
1620
+ })
1621
+ })
1622
+ ```
1623
+
1624
+ - [ ] **Step 2: テストを実行(失敗を確認)**
1625
+
1626
+ ```bash
1627
+ npx vitest run tests/resolver.test.ts -t "include"
1628
+ ```
1629
+
1630
+ Expected: FAIL(`loadInclude` throws "not yet supported")
1631
+
1632
+ - [ ] **Step 3: `loadInclude` を `resolver.ts` で実装(stub を置き換える)**
1633
+
1634
+ ファイル先頭に以下の static import を追加する(`await import` は使わない):
1635
+
1636
+ ```ts
1637
+ import * as nodePath from 'node:path'
1638
+ import { tokenize } from '../lexer/lexer.js'
1639
+ import { parseTokens } from '../parser/parser.js'
1640
+ ```
1641
+
1642
+ `loadInclude` 関数を以下で置き換える:
1643
+
1644
+ ```ts
1645
+ function loadInclude(includePath: string, opts: ResolveOptions): ResObj {
1646
+ const { baseDir, readFileSync, includeStack = [], env } = opts
1647
+ const absPath = baseDir
1648
+ ? nodePath.resolve(baseDir, includePath)
1649
+ : nodePath.resolve(includePath)
1650
+
1651
+ if (includeStack.includes(absPath)) {
1652
+ throw new ResolveError(`circular include: ${absPath}`, absPath, 0, 0)
1653
+ }
1654
+
1655
+ const content = readFileSync(absPath)
1656
+ const ast = parseTokens(tokenize(content))
1657
+ return buildResObj(ast, {
1658
+ env,
1659
+ baseDir: nodePath.dirname(absPath),
1660
+ readFileSync,
1661
+ includeStack: [...includeStack, absPath],
1662
+ })
1663
+ }
1664
+ ```
1665
+
1666
+ - [ ] **Step 4: 全 Resolver テストを実行(pass を確認)**
1667
+
1668
+ ```bash
1669
+ npx vitest run tests/resolver.test.ts
1670
+ ```
1671
+
1672
+ Expected: 全テスト pass
1673
+
1674
+ - [ ] **Step 5: commit**
1675
+
1676
+ ```bash
1677
+ git add src/internal/resolver/resolver.ts tests/resolver.test.ts
1678
+ git commit -m "feat: implement include directive support in Resolver"
1679
+ ```
1680
+
1681
+ ---
1682
+
1683
+ ## Task 7: Config クラス
1684
+
1685
+ **Files:**
1686
+ - Create: `src/config.ts`
1687
+ - Create: `tests/config.test.ts`
1688
+
1689
+ - [ ] **Step 1: テストを書く**
1690
+
1691
+ ```ts
1692
+ // tests/config.test.ts
1693
+ import { describe, it, expect } from 'vitest'
1694
+ import { Config } from '../src/config.js'
1695
+ import type { HoconValue } from '../src/value.js'
1696
+ import { ConfigError } from '../src/errors.js'
1697
+
1698
+ function makeConfig(obj: Record<string, HoconValue>): Config {
1699
+ return new Config({ kind: 'object', fields: new Map(Object.entries(obj)) })
1700
+ }
1701
+
1702
+ describe('Config', () => {
1703
+ it('get() returns value at path', () => {
1704
+ const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
1705
+ expect(c.get('host')).toBe('localhost')
1706
+ })
1707
+
1708
+ it('get() returns nested value at dot-path', () => {
1709
+ const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
1710
+ const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
1711
+ expect(c.get('server.host')).toBe('localhost')
1712
+ })
1713
+
1714
+ it('get() returns undefined for missing path', () => {
1715
+ const c = makeConfig({})
1716
+ expect(c.get('missing')).toBeUndefined()
1717
+ })
1718
+
1719
+ it('getString() returns string value', () => {
1720
+ const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
1721
+ expect(c.getString('host')).toBe('localhost')
1722
+ })
1723
+
1724
+ it('getString() throws ConfigError for non-string', () => {
1725
+ const c = makeConfig({ port: { kind: 'scalar', value: 8080 } })
1726
+ expect(() => c.getString('port')).toThrow(ConfigError)
1727
+ })
1728
+
1729
+ it('getNumber() returns number value', () => {
1730
+ const c = makeConfig({ port: { kind: 'scalar', value: 8080 } })
1731
+ expect(c.getNumber('port')).toBe(8080)
1732
+ })
1733
+
1734
+ it('getNumber() throws ConfigError for non-number', () => {
1735
+ const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
1736
+ expect(() => c.getNumber('host')).toThrow(ConfigError)
1737
+ })
1738
+
1739
+ it('getBoolean() returns boolean value', () => {
1740
+ const c = makeConfig({ debug: { kind: 'scalar', value: true } })
1741
+ expect(c.getBoolean('debug')).toBe(true)
1742
+ })
1743
+
1744
+ it('getConfig() returns sub-config', () => {
1745
+ const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
1746
+ const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
1747
+ const sub = c.getConfig('server')
1748
+ expect(sub).toBeInstanceOf(Config)
1749
+ expect(sub.getString('host')).toBe('localhost')
1750
+ })
1751
+
1752
+ it('getList() returns array as unknown[]', () => {
1753
+ const items: HoconValue[] = [{ kind: 'scalar', value: 1 }, { kind: 'scalar', value: 2 }]
1754
+ const c = makeConfig({ list: { kind: 'array', items } })
1755
+ expect(c.getList('list')).toEqual([1, 2])
1756
+ })
1757
+
1758
+ it('has() returns true for existing key', () => {
1759
+ const c = makeConfig({ host: { kind: 'scalar', value: 'localhost' } })
1760
+ expect(c.has('host')).toBe(true)
1761
+ })
1762
+
1763
+ it('has() returns true for null value', () => {
1764
+ const c = makeConfig({ x: { kind: 'scalar', value: null } })
1765
+ expect(c.has('x')).toBe(true)
1766
+ })
1767
+
1768
+ it('has() returns false for missing key', () => {
1769
+ const c = makeConfig({})
1770
+ expect(c.has('missing')).toBe(false)
1771
+ })
1772
+
1773
+ it('keys() returns top-level keys in order', () => {
1774
+ const c = new Config({ kind: 'object', fields: new Map([['b', { kind: 'scalar', value: 2 }], ['a', { kind: 'scalar', value: 1 }]]) })
1775
+ expect(c.keys()).toEqual(['b', 'a'])
1776
+ })
1777
+
1778
+ it('withFallback() receiver wins', () => {
1779
+ const c1 = makeConfig({ host: { kind: 'scalar', value: 'prod' } })
1780
+ const c2 = makeConfig({ host: { kind: 'scalar', value: 'dev' }, port: { kind: 'scalar', value: 8080 } })
1781
+ const merged = c1.withFallback(c2)
1782
+ expect(merged.getString('host')).toBe('prod')
1783
+ expect(merged.getNumber('port')).toBe(8080)
1784
+ })
1785
+
1786
+ it('toObject() converts to plain JS object recursively', () => {
1787
+ const inner = new Map([['host', { kind: 'scalar', value: 'localhost' } satisfies HoconValue]])
1788
+ const c = new Config({ kind: 'object', fields: new Map([['server', { kind: 'object', fields: inner }]]) })
1789
+ expect(c.toObject()).toEqual({ server: { host: 'localhost' } })
1790
+ })
1791
+ })
1792
+ ```
1793
+
1794
+ - [ ] **Step 2: テストを実行(失敗を確認)**
1795
+
1796
+ ```bash
1797
+ npx vitest run tests/config.test.ts
1798
+ ```
1799
+
1800
+ Expected: FAIL
1801
+
1802
+ - [ ] **Step 3: `src/config.ts` を実装**
1803
+
1804
+ ```ts
1805
+ import { ConfigError } from './errors.js'
1806
+ import type { HoconValue } from './value.js'
1807
+
1808
+ export class Config {
1809
+ constructor(private readonly root: HoconValue & { kind: 'object' }) {}
1810
+
1811
+ get(path: string): unknown {
1812
+ const v = this.lookup(path)
1813
+ if (v === undefined) return undefined
1814
+ return hoconToJs(v)
1815
+ }
1816
+
1817
+ getString(path: string): string {
1818
+ const v = this.requireScalar(path)
1819
+ if (typeof v !== 'string') throw new ConfigError(`expected string at ${path}, got ${typeof v}`, path)
1820
+ return v
1821
+ }
1822
+
1823
+ getNumber(path: string): number {
1824
+ const v = this.requireScalar(path)
1825
+ if (typeof v !== 'number') throw new ConfigError(`expected number at ${path}, got ${typeof v}`, path)
1826
+ return v
1827
+ }
1828
+
1829
+ getBoolean(path: string): boolean {
1830
+ const v = this.requireScalar(path)
1831
+ if (typeof v !== 'boolean') throw new ConfigError(`expected boolean at ${path}, got ${typeof v}`, path)
1832
+ return v
1833
+ }
1834
+
1835
+ getConfig(path: string): Config {
1836
+ const v = this.lookupNode(path)
1837
+ if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
1838
+ if (v.kind !== 'object') throw new ConfigError(`expected object at ${path}`, path)
1839
+ return new Config(v)
1840
+ }
1841
+
1842
+ getList(path: string): unknown[] {
1843
+ const v = this.lookupNode(path)
1844
+ if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
1845
+ if (v.kind !== 'array') throw new ConfigError(`expected array at ${path}`, path)
1846
+ return v.items.map(hoconToJs)
1847
+ }
1848
+
1849
+ has(path: string): boolean {
1850
+ return this.lookupNode(path) !== undefined
1851
+ }
1852
+
1853
+ keys(): string[] {
1854
+ return [...this.root.fields.keys()]
1855
+ }
1856
+
1857
+ withFallback(fallback: Config): Config {
1858
+ const merged = deepMergeHocon(this.root, fallback.root)
1859
+ return new Config(merged)
1860
+ }
1861
+
1862
+ toObject(): unknown {
1863
+ return hoconToJs(this.root)
1864
+ }
1865
+
1866
+ private lookupNode(path: string): HoconValue | undefined {
1867
+ const segments = path.split('.')
1868
+ let current: HoconValue = this.root
1869
+ for (const seg of segments) {
1870
+ if (current.kind !== 'object') return undefined
1871
+ const next = current.fields.get(seg)
1872
+ if (next === undefined) return undefined
1873
+ current = next
1874
+ }
1875
+ return current
1876
+ }
1877
+
1878
+ private lookup(path: string): HoconValue | undefined {
1879
+ return this.lookupNode(path)
1880
+ }
1881
+
1882
+ private requireScalar(path: string): string | number | boolean | null {
1883
+ const v = this.lookupNode(path)
1884
+ if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
1885
+ if (v.kind !== 'scalar') throw new ConfigError(`expected scalar at ${path}, got ${v.kind}`, path)
1886
+ return v.value
1887
+ }
1888
+ }
1889
+
1890
+ function hoconToJs(v: HoconValue): unknown {
1891
+ switch (v.kind) {
1892
+ case 'scalar': return v.value
1893
+ case 'array': return v.items.map(hoconToJs)
1894
+ case 'object': {
1895
+ const obj: Record<string, unknown> = {}
1896
+ for (const [k, val] of v.fields) obj[k] = hoconToJs(val)
1897
+ return obj
1898
+ }
1899
+ }
1900
+ }
1901
+
1902
+ function deepMergeHocon(
1903
+ receiver: HoconValue & { kind: 'object' },
1904
+ fallback: HoconValue & { kind: 'object' },
1905
+ ): HoconValue & { kind: 'object' } {
1906
+ const merged = new Map(fallback.fields)
1907
+ for (const [k, v] of receiver.fields) {
1908
+ const fb = merged.get(k)
1909
+ if (fb?.kind === 'object' && v.kind === 'object') {
1910
+ merged.set(k, deepMergeHocon(v, fb))
1911
+ } else {
1912
+ merged.set(k, v)
1913
+ }
1914
+ }
1915
+ return { kind: 'object', fields: merged }
1916
+ }
1917
+ ```
1918
+
1919
+ - [ ] **Step 4: テストを実行(pass を確認)**
1920
+
1921
+ ```bash
1922
+ npx vitest run tests/config.test.ts
1923
+ ```
1924
+
1925
+ Expected: 全テスト pass
1926
+
1927
+ - [ ] **Step 5: commit**
1928
+
1929
+ ```bash
1930
+ git add src/config.ts tests/config.test.ts
1931
+ git commit -m "feat: implement Config class"
1932
+ ```
1933
+
1934
+ ---
1935
+
1936
+ ## Task 8: Parse 関数 + public exports
1937
+
1938
+ **Files:**
1939
+ - Create: `src/parse.ts`
1940
+ - Modify: `src/index.ts`
1941
+ - Create: `tests/parse.test.ts`
1942
+
1943
+ - [ ] **Step 1: 統合テストを書く**
1944
+
1945
+ ```ts
1946
+ // tests/parse.test.ts
1947
+ import { describe, it, expect } from 'vitest'
1948
+ import { parse, parseAsync } from '../src/parse.js'
1949
+ import { ParseError, ResolveError } from '../src/errors.js'
1950
+
1951
+ describe('parse()', () => {
1952
+ it('parses basic config', () => {
1953
+ const c = parse('server { host = "localhost"\nport = 8080 }')
1954
+ expect(c.getString('server.host')).toBe('localhost')
1955
+ expect(c.getNumber('server.port')).toBe(8080)
1956
+ })
1957
+
1958
+ it('resolves substitutions end-to-end', () => {
1959
+ const c = parse('base = "hello"\ngreeting = ${base}" world"')
1960
+ expect(c.getString('greeting')).toBe('hello world')
1961
+ })
1962
+
1963
+ it('uses env option for substitution fallback', () => {
1964
+ const c = parse('val = ${MY_ENV}', { env: { MY_ENV: 'from-env' } })
1965
+ expect(c.getString('val')).toBe('from-env')
1966
+ })
1967
+
1968
+ it('uses process.env as default env', () => {
1969
+ process.env['HOCON_TEST_VAR'] = 'test-value'
1970
+ const c = parse('val = ${HOCON_TEST_VAR}')
1971
+ expect(c.getString('val')).toBe('test-value')
1972
+ delete process.env['HOCON_TEST_VAR']
1973
+ })
1974
+
1975
+ it('throws ParseError for syntax errors', () => {
1976
+ expect(() => parse('{ unclosed')).toThrow(ParseError)
1977
+ })
1978
+
1979
+ it('throws ResolveError for unresolved substitution', () => {
1980
+ expect(() => parse('x = ${missing}')).toThrow(ResolveError)
1981
+ })
1982
+ })
1983
+
1984
+ describe('parseAsync()', () => {
1985
+ it('parses basic config asynchronously', async () => {
1986
+ const c = await parseAsync('host = "localhost"')
1987
+ expect(c.getString('host')).toBe('localhost')
1988
+ })
1989
+ })
1990
+ ```
1991
+
1992
+ - [ ] **Step 2: テストを実行(失敗を確認)**
1993
+
1994
+ ```bash
1995
+ npx vitest run tests/parse.test.ts
1996
+ ```
1997
+
1998
+ Expected: FAIL
1999
+
2000
+ - [ ] **Step 3: `src/parse.ts` を実装**
2001
+
2002
+ ```ts
2003
+ import { tokenize } from './internal/lexer/lexer.js'
2004
+ import { parseTokens } from './internal/parser/parser.js'
2005
+ import { resolve } from './internal/resolver/resolver.js'
2006
+ import { Config } from './config.js'
2007
+ import type { ResolveOptions } from './internal/resolver/resolver.js'
2008
+ import * as fs from 'node:fs'
2009
+ import * as path from 'node:path'
2010
+
2011
+ export type ParseOptions = {
2012
+ baseDir?: string
2013
+ env?: Record<string, string>
2014
+ readFile?: (filePath: string) => Promise<string>
2015
+ readFileSync?: (filePath: string) => string
2016
+ }
2017
+
2018
+ function getEnv(opts: ParseOptions): Record<string, string> {
2019
+ if (opts.env !== undefined) return opts.env
2020
+ if (typeof process !== 'undefined' && process.env) return process.env as Record<string, string>
2021
+ return {}
2022
+ }
2023
+
2024
+ function defaultReadFileSync(filePath: string): string {
2025
+ return fs.readFileSync(filePath, 'utf-8')
2026
+ }
2027
+
2028
+ async function defaultReadFile(filePath: string): Promise<string> {
2029
+ return fs.promises.readFile(filePath, 'utf-8')
2030
+ }
2031
+
2032
+ export function parse(input: string, opts: ParseOptions = {}): Config {
2033
+ const tokens = tokenize(input)
2034
+ const ast = parseTokens(tokens)
2035
+ const resolveOpts: ResolveOptions = {
2036
+ env: getEnv(opts),
2037
+ baseDir: opts.baseDir,
2038
+ readFileSync: opts.readFileSync ?? defaultReadFileSync,
2039
+ }
2040
+ const value = resolve(ast, resolveOpts)
2041
+ if (value.kind !== 'object') throw new Error('resolved value is not an object')
2042
+ return new Config(value)
2043
+ }
2044
+
2045
+ export async function parseAsync(input: string, opts: ParseOptions = {}): Promise<Config> {
2046
+ // string input: sync parse, wrap in Promise
2047
+ // (if include triggers readFile, that will be async in a future implementation)
2048
+ return Promise.resolve(parse(input, opts))
2049
+ }
2050
+
2051
+ export function parseFile(filePath: string, opts: ParseOptions = {}): Config {
2052
+ if (typeof process === 'undefined' && !opts.readFileSync) {
2053
+ throw new Error('parseFile is not supported in browser environments. Provide opts.readFileSync or use parse() instead.')
2054
+ }
2055
+ const resolvedPath = path.resolve(filePath)
2056
+ const readFileSync = opts.readFileSync ?? defaultReadFileSync
2057
+ const input = readFileSync(resolvedPath)
2058
+ return parse(input, {
2059
+ ...opts,
2060
+ baseDir: opts.baseDir ?? path.dirname(resolvedPath),
2061
+ readFileSync,
2062
+ })
2063
+ }
2064
+
2065
+ export async function parseFileAsync(filePath: string, opts: ParseOptions = {}): Promise<Config> {
2066
+ if (typeof process === 'undefined' && !opts.readFile) {
2067
+ throw new Error('parseFileAsync is not supported in browser environments. Provide opts.readFile or use parseAsync() instead.')
2068
+ }
2069
+ const resolvedPath = path.resolve(filePath)
2070
+ const readFile = opts.readFile ?? defaultReadFile
2071
+ const input = await readFile(resolvedPath)
2072
+ return parse(input, {
2073
+ ...opts,
2074
+ baseDir: opts.baseDir ?? path.dirname(resolvedPath),
2075
+ readFileSync: opts.readFileSync ?? defaultReadFileSync,
2076
+ })
2077
+ }
2078
+ ```
2079
+
2080
+ - [ ] **Step 4: `src/index.ts` を更新**
2081
+
2082
+ ```ts
2083
+ export { parse, parseAsync, parseFile, parseFileAsync } from './parse.js'
2084
+ export type { ParseOptions } from './parse.js'
2085
+ export { Config } from './config.js'
2086
+ export { ParseError, ResolveError, ConfigError } from './errors.js'
2087
+ export type { HoconValue } from './value.js'
2088
+ ```
2089
+
2090
+ - [ ] **Step 5: テストを実行(pass を確認)**
2091
+
2092
+ ```bash
2093
+ npx vitest run tests/parse.test.ts
2094
+ ```
2095
+
2096
+ Expected: 全テスト pass
2097
+
2098
+ - [ ] **Step 6: 全テストをまとめて実行**
2099
+
2100
+ ```bash
2101
+ npx vitest run
2102
+ ```
2103
+
2104
+ Expected: 全テスト pass
2105
+
2106
+ - [ ] **Step 7: commit**
2107
+
2108
+ ```bash
2109
+ git add src/parse.ts src/index.ts tests/parse.test.ts
2110
+ git commit -m "feat: add parse functions and public exports"
2111
+ ```
2112
+
2113
+ ---
2114
+
2115
+ ## Task 9: Lightbend 仕様準拠テスト
2116
+
2117
+ **Files:**
2118
+ - Create: `tests/lightbend/` ディレクトリ(go.hocon からコピー)
2119
+ - Create: `tests/lightbend/lightbend.test.ts`
2120
+
2121
+ - [ ] **Step 1: テストデータをコピー**
2122
+
2123
+ ```bash
2124
+ cp -r /Volumes/Workspace/o3co/repos/go.hocon/testdata/hocon tests/lightbend/testdata
2125
+ ```
2126
+
2127
+ - [ ] **Step 2: lightbend テストを書く**
2128
+
2129
+ Go 版の `/Volumes/Workspace/o3co/repos/go.hocon/lightbend_test.go` を参考に、各グループの `*.conf` を parse して対応する `*.json` と一致するか検証する。
2130
+
2131
+ ```ts
2132
+ // tests/lightbend/lightbend.test.ts
2133
+ import { describe, it, expect } from 'vitest'
2134
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2135
+ import { join, relative } from 'node:path'
2136
+ import { parse } from '../../src/parse.js'
2137
+
2138
+ const dataDir = new URL('./testdata', import.meta.url).pathname
2139
+
2140
+ // Relative paths (from dataDir) to skip — unsupported features in v1
2141
+ const SKIP = new Set([
2142
+ 'equiv03/includes.conf', // requires .properties file support
2143
+ ])
2144
+
2145
+ // Relative paths expected to throw (invalid HOCON)
2146
+ const EXPECT_THROW = new Set([
2147
+ 'cycle.conf',
2148
+ 'validate-invalid.conf',
2149
+ ])
2150
+
2151
+ function findConfFiles(dir: string): string[] {
2152
+ const results: string[] = []
2153
+ for (const entry of readdirSync(dir)) {
2154
+ const fullPath = join(dir, entry)
2155
+ if (statSync(fullPath).isDirectory()) {
2156
+ results.push(...findConfFiles(fullPath))
2157
+ } else if (entry.endsWith('.conf')) {
2158
+ results.push(fullPath)
2159
+ }
2160
+ }
2161
+ return results
2162
+ }
2163
+
2164
+ const confFiles = findConfFiles(dataDir)
2165
+
2166
+ describe('Lightbend HOCON spec compliance', () => {
2167
+ for (const confPath of confFiles) {
2168
+ const rel = relative(dataDir, confPath)
2169
+ if (SKIP.has(rel)) continue
2170
+
2171
+ it(rel, () => {
2172
+ const baseDir = confPath.substring(0, confPath.lastIndexOf('/'))
2173
+ const confContent = readFileSync(confPath, 'utf-8')
2174
+
2175
+ if (EXPECT_THROW.has(rel)) {
2176
+ expect(() => parse(confContent, { baseDir })).toThrow()
2177
+ return
2178
+ }
2179
+
2180
+ const jsonPath = confPath.replace('.conf', '.json')
2181
+ let expected: unknown
2182
+ try {
2183
+ expected = JSON.parse(readFileSync(jsonPath, 'utf-8'))
2184
+ } catch {
2185
+ // No corresponding JSON — just check no throw
2186
+ expect(() => parse(confContent, { baseDir })).not.toThrow()
2187
+ return
2188
+ }
2189
+
2190
+ const config = parse(confContent, { baseDir })
2191
+ expect(config.toObject()).toEqual(expected)
2192
+ })
2193
+ }
2194
+ })
2195
+ ```
2196
+
2197
+ - [ ] **Step 3: Lightbend テストを実行(初回: 何件 pass するか確認)**
2198
+
2199
+ ```bash
2200
+ npx vitest run tests/lightbend/lightbend.test.ts
2201
+ ```
2202
+
2203
+ Expected: 一部 fail(現時点での実装カバレッジを確認)
2204
+
2205
+ - [ ] **Step 4: 失敗している項目を修正(Go 版の実装を参照しながら)**
2206
+
2207
+ 失敗しているテストを見て、`lexer.ts` / `parser.ts` / `resolver.ts` の不足を補う。
2208
+ 主な修正候補:
2209
+ - multiline strings
2210
+ - object merging edge cases
2211
+ - duration/bytes 文字列(Lightbend では `toObject()` に文字列として残るため特別な処理は不要)
2212
+ - include directive
2213
+
2214
+ - [ ] **Step 5: 全 Lightbend テストが pass することを確認**
2215
+
2216
+ ```bash
2217
+ npx vitest run tests/lightbend/lightbend.test.ts
2218
+ ```
2219
+
2220
+ Expected: go.hocon と同等の合格率(12/13 グループ)
2221
+
2222
+ - [ ] **Step 6: commit**
2223
+
2224
+ ```bash
2225
+ git add tests/lightbend/
2226
+ git commit -m "test: add Lightbend HOCON spec compliance tests"
2227
+ ```
2228
+
2229
+ ---
2230
+
2231
+ ## Task 10: Zod インテグレーション
2232
+
2233
+ **Files:**
2234
+ - Create: `src/zod.ts`
2235
+ - Create: `tests/zod.test.ts`
2236
+
2237
+ - [ ] **Step 1: Zod を devDependency としてインストール(テスト用)**
2238
+
2239
+ ```bash
2240
+ npm install -D zod
2241
+ ```
2242
+
2243
+ - [ ] **Step 2: テストを書く**
2244
+
2245
+ ```ts
2246
+ // tests/zod.test.ts
2247
+ import { describe, it, expect } from 'vitest'
2248
+ import { z } from 'zod'
2249
+ import { parse } from '../src/parse.js'
2250
+ import { validate, getValidated } from '../src/zod.js'
2251
+
2252
+ const cfg = parse(`
2253
+ server {
2254
+ host = "localhost"
2255
+ port = 8080
2256
+ }
2257
+ debug = false
2258
+ `)
2259
+
2260
+ describe('validate()', () => {
2261
+ it('validates and returns typed object', () => {
2262
+ const Schema = z.object({
2263
+ server: z.object({
2264
+ host: z.string(),
2265
+ port: z.number().int(),
2266
+ }),
2267
+ debug: z.boolean(),
2268
+ })
2269
+ const result = validate(cfg, Schema)
2270
+ expect(result.server.host).toBe('localhost')
2271
+ expect(result.server.port).toBe(8080)
2272
+ expect(result.debug).toBe(false)
2273
+ })
2274
+
2275
+ it('throws ZodError on schema mismatch', () => {
2276
+ const Schema = z.object({ server: z.object({ host: z.number() }) })
2277
+ expect(() => validate(cfg, Schema)).toThrow()
2278
+ })
2279
+
2280
+ it('supports Zod transforms (e.g., coerce)', () => {
2281
+ const c = parse('timeout = "30"')
2282
+ const Schema = z.object({ timeout: z.coerce.number() })
2283
+ const result = validate(c, Schema)
2284
+ expect(result.timeout).toBe(30)
2285
+ })
2286
+ })
2287
+
2288
+ describe('getValidated()', () => {
2289
+ it('returns typed value at path', () => {
2290
+ const port = getValidated(cfg, 'server.port', z.number().int())
2291
+ expect(port).toBe(8080)
2292
+ // TypeScript should infer `port` as `number`
2293
+ const _check: number = port
2294
+ })
2295
+
2296
+ it('throws ZodError on schema mismatch at path', () => {
2297
+ expect(() => getValidated(cfg, 'server.host', z.number())).toThrow()
2298
+ })
2299
+
2300
+ it('throws ConfigError on missing path', () => {
2301
+ expect(() => getValidated(cfg, 'missing.path', z.string())).toThrow()
2302
+ })
2303
+ })
2304
+ ```
2305
+
2306
+ - [ ] **Step 3: テストを実行(失敗を確認)**
2307
+
2308
+ ```bash
2309
+ npx vitest run tests/zod.test.ts
2310
+ ```
2311
+
2312
+ Expected: FAIL
2313
+
2314
+ - [ ] **Step 4: `src/zod.ts` を実装**
2315
+
2316
+ ```ts
2317
+ import type { ZodType } from 'zod'
2318
+ import type { Config } from './config.js'
2319
+
2320
+ export function validate<T>(config: Config, schema: ZodType<T>): T {
2321
+ const plain = config.toObject()
2322
+ return schema.parse(plain)
2323
+ }
2324
+
2325
+ export function getValidated<T>(config: Config, path: string, schema: ZodType<T>): T {
2326
+ const val = config.get(path)
2327
+ return schema.parse(val)
2328
+ }
2329
+ ```
2330
+
2331
+ - [ ] **Step 5: テストを実行(pass を確認)**
2332
+
2333
+ ```bash
2334
+ npx vitest run tests/zod.test.ts
2335
+ ```
2336
+
2337
+ Expected: 全テスト pass
2338
+
2339
+ - [ ] **Step 6: commit**
2340
+
2341
+ ```bash
2342
+ git add src/zod.ts tests/zod.test.ts
2343
+ git commit -m "feat: add Zod integration (validate, getValidated)"
2344
+ ```
2345
+
2346
+ ---
2347
+
2348
+ ## Task 11: ビルド設定と最終確認
2349
+
2350
+ **Files:**
2351
+ - Verify: `tsup.config.ts`
2352
+ - Verify: `package.json`
2353
+
2354
+ - [ ] **Step 1: 全テストを通す**
2355
+
2356
+ ```bash
2357
+ npx vitest run
2358
+ ```
2359
+
2360
+ Expected: 全テスト pass
2361
+
2362
+ - [ ] **Step 2: TypeScript 型チェック**
2363
+
2364
+ ```bash
2365
+ npx tsc --noEmit
2366
+ ```
2367
+
2368
+ Expected: エラーなし
2369
+
2370
+ - [ ] **Step 3: ビルドを実行**
2371
+
2372
+ ```bash
2373
+ npx tsup
2374
+ ```
2375
+
2376
+ Expected: `dist/` に以下が生成される
2377
+ - `dist/index.js`(ESM)
2378
+ - `dist/index.cjs`(CJS)
2379
+ - `dist/index.d.ts`(型定義)
2380
+ - `dist/zod.js`, `dist/zod.cjs`, `dist/zod.d.ts`
2381
+
2382
+ - [ ] **Step 4: ビルド成果物の動作確認(Node.js で require)**
2383
+
2384
+ ```bash
2385
+ node -e "const { parse } = require('./dist/index.cjs'); const c = parse('x = 1'); console.log(c.getNumber('x'))"
2386
+ ```
2387
+
2388
+ Expected: `1` が出力される
2389
+
2390
+ - [ ] **Step 5: ビルド成果物の動作確認(ESM)**
2391
+
2392
+ ```bash
2393
+ node --input-type=module -e "import { parse } from './dist/index.js'; const c = parse('x = 1'); console.log(c.getNumber('x'))"
2394
+ ```
2395
+
2396
+ Expected: `1` が出力される
2397
+
2398
+ - [ ] **Step 6: final commit**
2399
+
2400
+ ```bash
2401
+ git add -A
2402
+ git commit -m "feat: complete @o3co/ts.hocon v0.1.0 initial implementation"
2403
+ ```
2404
+
2405
+ ---
2406
+
2407
+ ## 実装上の注意点
2408
+
2409
+ - Go 実装 (`/Volumes/Workspace/o3co/repos/go.hocon/`) を逐次参照すること。特に resolver のアルゴリズムは Go 版と同じロジックを踏む。
2410
+ - HOCON の concat ルールは複雑。Go 版の `lexer_test.go` と `parser_test.go` のテストケースを参考にすること。
2411
+ - `include` ディレクティブのパス解決は `node:path` の `path.resolve` / `path.dirname` を使うこと。
2412
+ - ブラウザ環境では `fs` が使えないため、`parse()` / `parseAsync()` のデフォルト `readFileSync` は実際には呼ばれない(`include` なしの場合)。