@ontrails/config 1.0.0-beta.12

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 (159) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +19 -0
  5. package/dist/app-config.d.ts +65 -0
  6. package/dist/app-config.d.ts.map +1 -0
  7. package/dist/app-config.js +172 -0
  8. package/dist/app-config.js.map +1 -0
  9. package/dist/collect.d.ts +11 -0
  10. package/dist/collect.d.ts.map +1 -0
  11. package/dist/collect.js +81 -0
  12. package/dist/collect.js.map +1 -0
  13. package/dist/compose.d.ts +26 -0
  14. package/dist/compose.d.ts.map +1 -0
  15. package/dist/compose.js +19 -0
  16. package/dist/compose.js.map +1 -0
  17. package/dist/config-layer.d.ts +11 -0
  18. package/dist/config-layer.d.ts.map +1 -0
  19. package/dist/config-layer.js +6 -0
  20. package/dist/config-layer.js.map +1 -0
  21. package/dist/config-service.d.ts +3 -0
  22. package/dist/config-service.d.ts.map +1 -0
  23. package/dist/config-service.js +26 -0
  24. package/dist/config-service.js.map +1 -0
  25. package/dist/define-config.d.ts +61 -0
  26. package/dist/define-config.d.ts.map +1 -0
  27. package/dist/define-config.js +90 -0
  28. package/dist/define-config.js.map +1 -0
  29. package/dist/describe.d.ts +25 -0
  30. package/dist/describe.d.ts.map +1 -0
  31. package/dist/describe.js +147 -0
  32. package/dist/describe.js.map +1 -0
  33. package/dist/doctor.d.ts +27 -0
  34. package/dist/doctor.d.ts.map +1 -0
  35. package/dist/doctor.js +167 -0
  36. package/dist/doctor.js.map +1 -0
  37. package/dist/explain.d.ts +30 -0
  38. package/dist/explain.d.ts.map +1 -0
  39. package/dist/explain.js +114 -0
  40. package/dist/explain.js.map +1 -0
  41. package/dist/extensions.d.ts +38 -0
  42. package/dist/extensions.d.ts.map +1 -0
  43. package/dist/extensions.js +35 -0
  44. package/dist/extensions.js.map +1 -0
  45. package/dist/generate/env.d.ts +15 -0
  46. package/dist/generate/env.d.ts.map +1 -0
  47. package/dist/generate/env.js +65 -0
  48. package/dist/generate/env.js.map +1 -0
  49. package/dist/generate/example.d.ts +16 -0
  50. package/dist/generate/example.d.ts.map +1 -0
  51. package/dist/generate/example.js +136 -0
  52. package/dist/generate/example.js.map +1 -0
  53. package/dist/generate/helpers.d.ts +35 -0
  54. package/dist/generate/helpers.d.ts.map +1 -0
  55. package/dist/generate/helpers.js +116 -0
  56. package/dist/generate/helpers.js.map +1 -0
  57. package/dist/generate/index.d.ts +4 -0
  58. package/dist/generate/index.d.ts.map +1 -0
  59. package/dist/generate/index.js +4 -0
  60. package/dist/generate/index.js.map +1 -0
  61. package/dist/generate/json-schema.d.ts +18 -0
  62. package/dist/generate/json-schema.d.ts.map +1 -0
  63. package/dist/generate/json-schema.js +97 -0
  64. package/dist/generate/json-schema.js.map +1 -0
  65. package/dist/index.d.ts +21 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +21 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/merge.d.ts +16 -0
  70. package/dist/merge.d.ts.map +1 -0
  71. package/dist/merge.js +34 -0
  72. package/dist/merge.js.map +1 -0
  73. package/dist/ref.d.ts +24 -0
  74. package/dist/ref.d.ts.map +1 -0
  75. package/dist/ref.js +25 -0
  76. package/dist/ref.js.map +1 -0
  77. package/dist/registry.d.ts +24 -0
  78. package/dist/registry.d.ts.map +1 -0
  79. package/dist/registry.js +12 -0
  80. package/dist/registry.js.map +1 -0
  81. package/dist/resolve.d.ts +21 -0
  82. package/dist/resolve.d.ts.map +1 -0
  83. package/dist/resolve.js +174 -0
  84. package/dist/resolve.js.map +1 -0
  85. package/dist/secret-heuristics.d.ts +10 -0
  86. package/dist/secret-heuristics.d.ts.map +1 -0
  87. package/dist/secret-heuristics.js +11 -0
  88. package/dist/secret-heuristics.js.map +1 -0
  89. package/dist/trails/config-check.d.ts +11 -0
  90. package/dist/trails/config-check.d.ts.map +1 -0
  91. package/dist/trails/config-check.js +53 -0
  92. package/dist/trails/config-check.js.map +1 -0
  93. package/dist/trails/config-describe.d.ts +12 -0
  94. package/dist/trails/config-describe.d.ts.map +1 -0
  95. package/dist/trails/config-describe.js +41 -0
  96. package/dist/trails/config-describe.js.map +1 -0
  97. package/dist/trails/config-explain.d.ts +8 -0
  98. package/dist/trails/config-explain.d.ts.map +1 -0
  99. package/dist/trails/config-explain.js +74 -0
  100. package/dist/trails/config-explain.js.map +1 -0
  101. package/dist/trails/config-init.d.ts +9 -0
  102. package/dist/trails/config-init.d.ts.map +1 -0
  103. package/dist/trails/config-init.js +78 -0
  104. package/dist/trails/config-init.js.map +1 -0
  105. package/dist/workspace.d.ts +9 -0
  106. package/dist/workspace.d.ts.map +1 -0
  107. package/dist/workspace.js +44 -0
  108. package/dist/workspace.js.map +1 -0
  109. package/dist/zod-utils.d.ts +14 -0
  110. package/dist/zod-utils.d.ts.map +1 -0
  111. package/dist/zod-utils.js +41 -0
  112. package/dist/zod-utils.js.map +1 -0
  113. package/package.json +20 -0
  114. package/src/__tests__/app-config.test.ts +329 -0
  115. package/src/__tests__/compose.test.ts +59 -0
  116. package/src/__tests__/config-check.test.ts +171 -0
  117. package/src/__tests__/config-describe.test.ts +154 -0
  118. package/src/__tests__/config-explain.test.ts +167 -0
  119. package/src/__tests__/config-init.test.ts +210 -0
  120. package/src/__tests__/config-layer.test.ts +53 -0
  121. package/src/__tests__/config-service.test.ts +87 -0
  122. package/src/__tests__/define-config.test.ts +263 -0
  123. package/src/__tests__/describe.test.ts +158 -0
  124. package/src/__tests__/doctor.test.ts +172 -0
  125. package/src/__tests__/explain.test.ts +139 -0
  126. package/src/__tests__/extensions.test.ts +134 -0
  127. package/src/__tests__/generate.test.ts +269 -0
  128. package/src/__tests__/ref.test.ts +35 -0
  129. package/src/__tests__/resolve.test.ts +246 -0
  130. package/src/__tests__/workspace.test.ts +64 -0
  131. package/src/app-config.ts +307 -0
  132. package/src/collect.ts +118 -0
  133. package/src/compose.ts +46 -0
  134. package/src/config-layer.ts +15 -0
  135. package/src/config-service.ts +32 -0
  136. package/src/define-config.ts +134 -0
  137. package/src/describe.ts +252 -0
  138. package/src/doctor.ts +219 -0
  139. package/src/explain.ts +176 -0
  140. package/src/extensions.ts +51 -0
  141. package/src/generate/env.ts +104 -0
  142. package/src/generate/example.ts +222 -0
  143. package/src/generate/helpers.ts +158 -0
  144. package/src/generate/index.ts +3 -0
  145. package/src/generate/json-schema.ts +137 -0
  146. package/src/index.ts +44 -0
  147. package/src/merge.ts +43 -0
  148. package/src/ref.ts +38 -0
  149. package/src/registry.ts +33 -0
  150. package/src/resolve.ts +279 -0
  151. package/src/secret-heuristics.ts +13 -0
  152. package/src/trails/config-check.ts +60 -0
  153. package/src/trails/config-describe.ts +44 -0
  154. package/src/trails/config-explain.ts +93 -0
  155. package/src/trails/config-init.ts +96 -0
  156. package/src/workspace.ts +51 -0
  157. package/src/zod-utils.ts +53 -0
  158. package/tsconfig.json +9 -0
  159. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { z } from 'zod';
4
+
5
+ import { collectServiceConfigs } from '../compose.js';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Tests
9
+ // ---------------------------------------------------------------------------
10
+
11
+ describe('collectServiceConfigs', () => {
12
+ test('extracts config schemas from services that declare them', () => {
13
+ const dbSchema = z.object({ url: z.string().url() });
14
+ const cacheSchema = z.object({ ttl: z.number() });
15
+
16
+ const services = [
17
+ { config: dbSchema, id: 'db.main' },
18
+ { config: cacheSchema, id: 'cache.main' },
19
+ ];
20
+
21
+ const entries = collectServiceConfigs(services);
22
+
23
+ expect(entries).toHaveLength(2);
24
+ expect(entries[0]).toEqual({ schema: dbSchema, serviceId: 'db.main' });
25
+ expect(entries[1]).toEqual({
26
+ schema: cacheSchema,
27
+ serviceId: 'cache.main',
28
+ });
29
+ });
30
+
31
+ test('excludes services without config', () => {
32
+ const schema = z.object({ url: z.string() });
33
+
34
+ const services = [
35
+ { config: schema, id: 'db.main' },
36
+ { id: 'counter.main' },
37
+ { config: undefined, id: 'logger.main' },
38
+ ];
39
+
40
+ const entries = collectServiceConfigs(services);
41
+
42
+ expect(entries).toHaveLength(1);
43
+ expect(entries[0]?.serviceId).toBe('db.main');
44
+ });
45
+
46
+ test('returns empty array when no services have config', () => {
47
+ const services = [{ id: 'counter.main' }, { id: 'logger.main' }];
48
+
49
+ const entries = collectServiceConfigs(services);
50
+
51
+ expect(entries).toEqual([]);
52
+ });
53
+
54
+ test('returns empty array for empty input', () => {
55
+ const entries = collectServiceConfigs([]);
56
+
57
+ expect(entries).toEqual([]);
58
+ });
59
+ });
@@ -0,0 +1,171 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createServiceLookup } from '@ontrails/core';
3
+ import type { TrailContext } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { configCheck } from '../trails/config-check.js';
7
+ import type { ConfigState } from '../registry.js';
8
+
9
+ /**
10
+ * Build a TrailContext with configService resolved in extensions.
11
+ */
12
+ const buildCtx = (state: ConfigState): TrailContext => {
13
+ const extensions = { config: state };
14
+ const ctx: TrailContext = {
15
+ cwd: '/tmp',
16
+ env: {},
17
+ extensions,
18
+ requestId: 'test',
19
+ service: undefined as unknown as TrailContext['service'],
20
+ signal: AbortSignal.timeout(5000),
21
+ workspaceRoot: '/tmp',
22
+ };
23
+ const withLookup = {
24
+ ...ctx,
25
+ service: createServiceLookup(() => withLookup),
26
+ };
27
+ return withLookup;
28
+ };
29
+
30
+ describe('config.check trail', () => {
31
+ describe('identity', () => {
32
+ test('has id "config.check"', () => {
33
+ expect(configCheck.id).toBe('config.check');
34
+ });
35
+
36
+ test('has kind "trail"', () => {
37
+ expect(configCheck.kind).toBe('trail');
38
+ });
39
+
40
+ test('has intent "read"', () => {
41
+ expect(configCheck.intent).toBe('read');
42
+ });
43
+
44
+ test('has infrastructure metadata', () => {
45
+ expect(configCheck.metadata).toEqual({ category: 'infrastructure' });
46
+ });
47
+
48
+ test('has output schema', () => {
49
+ expect(configCheck.output).toBeDefined();
50
+ });
51
+
52
+ test('declares configService dependency', () => {
53
+ expect(configCheck.services).toBeDefined();
54
+ expect(configCheck.services?.length).toBe(1);
55
+ });
56
+ });
57
+
58
+ describe('examples', () => {
59
+ test('has at least one example', () => {
60
+ expect(configCheck.examples?.length).toBeGreaterThanOrEqual(1);
61
+ });
62
+ });
63
+
64
+ describe('wired behavior', () => {
65
+ test('reports valid when all required fields present', async () => {
66
+ const schema = z.object({
67
+ host: z.string().default('localhost'),
68
+ port: z.number().default(3000),
69
+ });
70
+ const state: ConfigState = {
71
+ resolved: { host: 'localhost', port: 3000 },
72
+ schema,
73
+ };
74
+ const ctx = buildCtx(state);
75
+ const result = await configCheck.run({ values: {} }, ctx);
76
+
77
+ expect(result.isOk()).toBe(true);
78
+ const value = result.unwrap();
79
+ expect(value.valid).toBe(true);
80
+ expect(value.diagnostics.length).toBeGreaterThan(0);
81
+ });
82
+
83
+ test('reports missing for required fields without values', async () => {
84
+ const schema = z.object({
85
+ host: z.string(),
86
+ port: z.number(),
87
+ });
88
+ const state: ConfigState = {
89
+ resolved: {},
90
+ schema,
91
+ };
92
+ const ctx = buildCtx(state);
93
+ const result = await configCheck.run({ values: {} }, ctx);
94
+
95
+ expect(result.isOk()).toBe(true);
96
+ const value = result.unwrap();
97
+ expect(value.valid).toBe(false);
98
+ const missing = value.diagnostics.filter((d) => d.status === 'missing');
99
+ expect(missing.length).toBe(2);
100
+ });
101
+
102
+ test('uses input values when provided to override resolved', async () => {
103
+ const schema = z.object({
104
+ port: z.number(),
105
+ });
106
+ const state: ConfigState = {
107
+ resolved: {},
108
+ schema,
109
+ };
110
+ const ctx = buildCtx(state);
111
+ const result = await configCheck.run({ values: { port: 8080 } }, ctx);
112
+
113
+ expect(result.isOk()).toBe(true);
114
+ const value = result.unwrap();
115
+ expect(value.valid).toBe(true);
116
+ expect(value.diagnostics.length).toBe(1);
117
+ expect(value.diagnostics[0]?.status).toBe('valid');
118
+ });
119
+
120
+ test('deep merges nested input overrides with resolved values', async () => {
121
+ const schema = z.object({
122
+ db: z.object({
123
+ host: z.string(),
124
+ port: z.number(),
125
+ }),
126
+ });
127
+ const state: ConfigState = {
128
+ resolved: { db: { host: 'localhost', port: 5432 } },
129
+ schema,
130
+ };
131
+ const ctx = buildCtx(state);
132
+ const result = await configCheck.run(
133
+ { values: { db: { port: 6543 } } },
134
+ ctx
135
+ );
136
+
137
+ expect(result.isOk()).toBe(true);
138
+ expect(result.unwrap().valid).toBe(true);
139
+ expect(result.unwrap().diagnostics).toEqual([
140
+ expect.objectContaining({
141
+ path: 'db.host',
142
+ status: 'valid',
143
+ value: 'localhost',
144
+ }),
145
+ expect.objectContaining({
146
+ path: 'db.port',
147
+ status: 'valid',
148
+ value: 6543,
149
+ }),
150
+ ]);
151
+ });
152
+
153
+ test('reports default status for fields using defaults', async () => {
154
+ const schema = z.object({
155
+ port: z.number().default(3000),
156
+ });
157
+ const state: ConfigState = {
158
+ resolved: {},
159
+ schema,
160
+ };
161
+ const ctx = buildCtx(state);
162
+ const result = await configCheck.run({ values: {} }, ctx);
163
+
164
+ expect(result.isOk()).toBe(true);
165
+ const defaults = result
166
+ .unwrap()
167
+ .diagnostics.filter((d) => d.status === 'default');
168
+ expect(defaults.length).toBe(1);
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createServiceLookup } from '@ontrails/core';
3
+ import type { TrailContext } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { configDescribe } from '../trails/config-describe.js';
7
+ import { env, secret } from '../extensions.js';
8
+ import type { ConfigState } from '../registry.js';
9
+
10
+ /**
11
+ * Build a TrailContext with configService resolved in extensions.
12
+ */
13
+ const buildCtx = (state: ConfigState): TrailContext => {
14
+ const extensions = { config: state };
15
+ const ctx: TrailContext = {
16
+ cwd: '/tmp',
17
+ env: {},
18
+ extensions,
19
+ requestId: 'test',
20
+ service: undefined as unknown as TrailContext['service'],
21
+ signal: AbortSignal.timeout(5000),
22
+ workspaceRoot: '/tmp',
23
+ };
24
+ const withLookup = {
25
+ ...ctx,
26
+ service: createServiceLookup(() => withLookup),
27
+ };
28
+ return withLookup;
29
+ };
30
+
31
+ describe('config.describe trail', () => {
32
+ describe('identity', () => {
33
+ test('has id "config.describe"', () => {
34
+ expect(configDescribe.id).toBe('config.describe');
35
+ });
36
+
37
+ test('has kind "trail"', () => {
38
+ expect(configDescribe.kind).toBe('trail');
39
+ });
40
+
41
+ test('has intent "read"', () => {
42
+ expect(configDescribe.intent).toBe('read');
43
+ });
44
+
45
+ test('has infrastructure metadata', () => {
46
+ expect(configDescribe.metadata).toEqual({ category: 'infrastructure' });
47
+ });
48
+
49
+ test('has output schema', () => {
50
+ expect(configDescribe.output).toBeDefined();
51
+ });
52
+
53
+ test('declares configService dependency', () => {
54
+ expect(configDescribe.services).toBeDefined();
55
+ expect(configDescribe.services?.length).toBe(1);
56
+ });
57
+ });
58
+
59
+ describe('examples', () => {
60
+ test('has at least one example', () => {
61
+ expect(configDescribe.examples?.length).toBeGreaterThanOrEqual(1);
62
+ });
63
+ });
64
+
65
+ describe('wired behavior', () => {
66
+ test('returns one field description per schema field', async () => {
67
+ const schema = z.object({
68
+ host: z.string().default('localhost'),
69
+ port: z.number().default(3000),
70
+ });
71
+ const state: ConfigState = {
72
+ resolved: { host: 'localhost', port: 3000 },
73
+ schema,
74
+ };
75
+ const ctx = buildCtx(state);
76
+ const result = await configDescribe.run({}, ctx);
77
+
78
+ expect(result.isOk()).toBe(true);
79
+ expect(result.unwrap().fields.length).toBe(2);
80
+ });
81
+
82
+ test('includes path and type for each field', async () => {
83
+ const schema = z.object({
84
+ host: z.string().default('localhost'),
85
+ port: z.number().default(3000),
86
+ });
87
+ const state: ConfigState = {
88
+ resolved: { host: 'localhost', port: 3000 },
89
+ schema,
90
+ };
91
+ const ctx = buildCtx(state);
92
+ const result = await configDescribe.run({}, ctx);
93
+ const { fields } = result.unwrap();
94
+
95
+ expect(fields[0]?.path).toBe('host');
96
+ expect(fields[0]?.type).toBe('string');
97
+ expect(fields[1]?.path).toBe('port');
98
+ expect(fields[1]?.type).toBe('number');
99
+ });
100
+
101
+ test('includes env annotation when present', async () => {
102
+ const schema = z.object({
103
+ port: env(z.number(), 'PORT').default(3000),
104
+ });
105
+ const state: ConfigState = {
106
+ resolved: { port: 3000 },
107
+ schema,
108
+ };
109
+ const ctx = buildCtx(state);
110
+ const result = await configDescribe.run({}, ctx);
111
+
112
+ expect(result.isOk()).toBe(true);
113
+ const [field] = result.unwrap().fields;
114
+ expect(field?.env).toBe('PORT');
115
+ });
116
+
117
+ test('includes secret annotation when present', async () => {
118
+ const schema = z.object({
119
+ token: secret(z.string()).default('tok'),
120
+ });
121
+ const state: ConfigState = {
122
+ resolved: { token: 'tok' },
123
+ schema,
124
+ };
125
+ const ctx = buildCtx(state);
126
+ const result = await configDescribe.run({}, ctx);
127
+
128
+ expect(result.isOk()).toBe(true);
129
+ const [field] = result.unwrap().fields;
130
+ expect(field?.secret).toBe(true);
131
+ });
132
+
133
+ test('reports required status correctly', async () => {
134
+ const schema = z.object({
135
+ optional: z.string().optional(),
136
+ required: z.string(),
137
+ withDefault: z.string().default('val'),
138
+ });
139
+ const state: ConfigState = {
140
+ resolved: { required: 'x', withDefault: 'val' },
141
+ schema,
142
+ };
143
+ const ctx = buildCtx(state);
144
+ const result = await configDescribe.run({}, ctx);
145
+
146
+ expect(result.isOk()).toBe(true);
147
+ const { fields } = result.unwrap();
148
+ const findField = (path: string) => fields.find((f) => f.path === path);
149
+ expect(findField('required')?.required).toBe(true);
150
+ expect(findField('optional')?.required).toBe(false);
151
+ expect(findField('withDefault')?.required).toBe(false);
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { createServiceLookup } from '@ontrails/core';
3
+ import type { TrailContext } from '@ontrails/core';
4
+ import { z } from 'zod';
5
+
6
+ import { configExplain } from '../trails/config-explain.js';
7
+ import { env } from '../extensions.js';
8
+ import type { ConfigState } from '../registry.js';
9
+
10
+ /**
11
+ * Build a TrailContext with configService resolved in extensions.
12
+ */
13
+ const buildCtx = (state: ConfigState): TrailContext => {
14
+ const extensions = { config: state };
15
+ const ctx: TrailContext = {
16
+ cwd: '/tmp',
17
+ env: {},
18
+ extensions,
19
+ requestId: 'test',
20
+ service: undefined as unknown as TrailContext['service'],
21
+ signal: AbortSignal.timeout(5000),
22
+ workspaceRoot: '/tmp',
23
+ };
24
+ const withLookup = {
25
+ ...ctx,
26
+ service: createServiceLookup(() => withLookup),
27
+ };
28
+ return withLookup;
29
+ };
30
+
31
+ describe('config.explain trail', () => {
32
+ describe('identity', () => {
33
+ test('has id "config.explain"', () => {
34
+ expect(configExplain.id).toBe('config.explain');
35
+ });
36
+
37
+ test('has kind "trail"', () => {
38
+ expect(configExplain.kind).toBe('trail');
39
+ });
40
+
41
+ test('has intent "read"', () => {
42
+ expect(configExplain.intent).toBe('read');
43
+ });
44
+
45
+ test('has infrastructure metadata', () => {
46
+ expect(configExplain.metadata).toEqual({ category: 'infrastructure' });
47
+ });
48
+
49
+ test('has output schema', () => {
50
+ expect(configExplain.output).toBeDefined();
51
+ });
52
+
53
+ test('declares configService dependency', () => {
54
+ expect(configExplain.services).toBeDefined();
55
+ expect(configExplain.services?.length).toBe(1);
56
+ });
57
+ });
58
+
59
+ describe('examples', () => {
60
+ test('has at least one example', () => {
61
+ expect(configExplain.examples?.length).toBeGreaterThanOrEqual(1);
62
+ });
63
+ });
64
+
65
+ describe('wired behavior', () => {
66
+ test('returns provenance entries for all fields', async () => {
67
+ const schema = z.object({
68
+ host: z.string().default('localhost'),
69
+ port: z.number().default(3000),
70
+ });
71
+ const state: ConfigState = {
72
+ resolved: { host: 'localhost', port: 3000 },
73
+ schema,
74
+ };
75
+ const ctx = buildCtx(state);
76
+ const result = await configExplain.run({ path: '' }, ctx);
77
+
78
+ expect(result.isOk()).toBe(true);
79
+ const value = result.unwrap();
80
+ expect(value.entries.length).toBe(2);
81
+ expect(value.entries[0]?.path).toBe('host');
82
+ expect(value.entries[0]?.source).toBe('default');
83
+ expect(value.entries[1]?.path).toBe('port');
84
+ });
85
+
86
+ test('filters entries by path prefix', async () => {
87
+ const schema = z.object({
88
+ db: z.object({
89
+ host: z.string().default('localhost'),
90
+ port: z.number().default(5432),
91
+ }),
92
+ name: z.string().default('app'),
93
+ });
94
+ const state: ConfigState = {
95
+ resolved: { db: { host: 'localhost', port: 5432 }, name: 'app' },
96
+ schema,
97
+ };
98
+ const ctx = buildCtx(state);
99
+ const result = await configExplain.run({ path: 'db' }, ctx);
100
+
101
+ expect(result.isOk()).toBe(true);
102
+ const value = result.unwrap();
103
+ expect(value.entries.length).toBe(2);
104
+ expect(value.entries[0]?.path).toBe('db.host');
105
+ });
106
+
107
+ test('does not match sibling roots that only share a prefix', async () => {
108
+ const schema = z.object({
109
+ db: z.object({
110
+ host: z.string().default('localhost'),
111
+ }),
112
+ dbReplica: z.object({
113
+ host: z.string().default('replica.local'),
114
+ }),
115
+ });
116
+ const state: ConfigState = {
117
+ resolved: {
118
+ db: { host: 'localhost' },
119
+ dbReplica: { host: 'replica.local' },
120
+ },
121
+ schema,
122
+ };
123
+ const ctx = buildCtx(state);
124
+ const result = await configExplain.run({ path: 'db' }, ctx);
125
+
126
+ expect(result.isOk()).toBe(true);
127
+ expect(result.unwrap().entries).toEqual([
128
+ expect.objectContaining({ path: 'db.host' }),
129
+ ]);
130
+ });
131
+
132
+ test('shows base layer as source when base provides value', async () => {
133
+ const schema = z.object({
134
+ port: z.number().default(3000),
135
+ });
136
+ const state: ConfigState = {
137
+ base: { port: 8080 },
138
+ resolved: { port: 8080 },
139
+ schema,
140
+ };
141
+ const ctx = buildCtx(state);
142
+ const result = await configExplain.run({ path: '' }, ctx);
143
+
144
+ expect(result.isOk()).toBe(true);
145
+ const [entry] = result.unwrap().entries;
146
+ expect(entry?.source).toBe('base');
147
+ expect(entry?.value).toBe(8080);
148
+ });
149
+
150
+ test('shows env as source when env provides value', async () => {
151
+ const schema = z.object({
152
+ token: env(z.string(), 'TOKEN').default('fallback'),
153
+ });
154
+ const state: ConfigState = {
155
+ env: { TOKEN: 'real-secret' },
156
+ resolved: { token: 'real-secret' },
157
+ schema,
158
+ };
159
+ const ctx = buildCtx(state);
160
+ const result = await configExplain.run({ path: '' }, ctx);
161
+
162
+ expect(result.isOk()).toBe(true);
163
+ const [entry] = result.unwrap().entries;
164
+ expect(entry?.source).toBe('env');
165
+ });
166
+ });
167
+ });