@ontrails/config 0.2.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.
@@ -0,0 +1,552 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import {
3
+ basename,
4
+ dirname,
5
+ isAbsolute,
6
+ posix,
7
+ relative,
8
+ resolve,
9
+ } from 'node:path';
10
+
11
+ import { NotFoundError, ValidationError } from '@ontrails/core';
12
+
13
+ import {
14
+ trailsAppEntryRelativePath,
15
+ trailsLocalConfigFileCandidates,
16
+ } from './trails-conventions.js';
17
+ import { canonicalBoundaryPath, isWithinBoundary } from './path-boundary.js';
18
+ import {
19
+ collectConfigBoundariesThroughPaths,
20
+ collectConfigPathsWithinBoundary,
21
+ combineConfigPaths,
22
+ findConfigPathsThroughBoundary,
23
+ } from './workspace-config-collection.js';
24
+ import {
25
+ parseTrailsProjectConfigFile,
26
+ staticIdentityError,
27
+ } from './workspace-config-source.js';
28
+
29
+ /** One authored lock-owning app in a Trails workspace. */
30
+ export interface TrailsWorkspaceAppConfig {
31
+ /** App-root-relative entry override. Omit to use the shared convention. */
32
+ readonly entry?: string | undefined;
33
+ /** Project-relative app root. */
34
+ readonly root: string;
35
+ }
36
+
37
+ /** Static workspace identity authored outside runtime config resolution. */
38
+ export interface TrailsWorkspaceConfig {
39
+ readonly apps: Readonly<Record<string, TrailsWorkspaceAppConfig>>;
40
+ }
41
+
42
+ /** A normalized workspace app ready for downstream project consumers. */
43
+ export interface ResolvedTrailsWorkspaceApp {
44
+ /** Normalized app-root-relative app entry. */
45
+ readonly entry: string;
46
+ /** Absolute resolved app entry path. */
47
+ readonly entryPath: string;
48
+ /** Whether the entry was authored or supplied by convention. */
49
+ readonly entrySource: 'convention' | 'explicit';
50
+ /** Deterministic stable app ID from the authored map key. */
51
+ readonly id: string;
52
+ /** Normalized project-relative module path. */
53
+ readonly modulePath: string;
54
+ /** Normalized project-relative app root. */
55
+ readonly root: string;
56
+ /** Absolute resolved app root. */
57
+ readonly rootDir: string;
58
+ }
59
+
60
+ /** Non-executing static identity read from the nearest authored config. */
61
+ export interface ReadTrailsProjectIdentityResult {
62
+ readonly apps: readonly ResolvedTrailsWorkspaceApp[];
63
+ readonly configPath?: string | undefined;
64
+ readonly rootDir: string;
65
+ readonly workspace?: TrailsWorkspaceConfig | undefined;
66
+ }
67
+
68
+ export interface ReadTrailsProjectIdentityOptions {
69
+ /** Inclusive discovery ceiling supplied by the collection-boundary owner. */
70
+ readonly boundaryDir: string;
71
+ /** Explicit config path, resolved from `startDir`. */
72
+ readonly configPath?: string | undefined;
73
+ /** Directory from which authored config discovery starts. */
74
+ readonly startDir?: string | undefined;
75
+ }
76
+
77
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
78
+ typeof value === 'object' && value !== null && !Array.isArray(value);
79
+
80
+ const localConfigFileNames = new Set<string>(trailsLocalConfigFileCandidates);
81
+
82
+ const compareStrings = (left: string, right: string): number => {
83
+ if (left < right) {
84
+ return -1;
85
+ }
86
+ if (left > right) {
87
+ return 1;
88
+ }
89
+ return 0;
90
+ };
91
+
92
+ const normalizeProjectRelativePath = (
93
+ value: unknown,
94
+ label: string,
95
+ filePath: string,
96
+ boundaryLabel = 'project root',
97
+ allowCurrentDirectory = false
98
+ ): string => {
99
+ if (typeof value !== 'string' || value.trim() === '') {
100
+ throw staticIdentityError(
101
+ `${label} must be a non-empty path relative to the ${boundaryLabel} in ${filePath}.`,
102
+ filePath,
103
+ 'invalid-path',
104
+ { value }
105
+ );
106
+ }
107
+
108
+ const portable = value.replaceAll('\\', '/');
109
+ if (
110
+ isAbsolute(portable) ||
111
+ portable.startsWith('/') ||
112
+ portable.includes('\0')
113
+ ) {
114
+ throw staticIdentityError(
115
+ `${label} must stay relative to the ${boundaryLabel}; received "${value}".`,
116
+ filePath,
117
+ 'invalid-path',
118
+ { value }
119
+ );
120
+ }
121
+
122
+ const normalized = posix.normalize(portable);
123
+ if (/^[A-Za-z][A-Za-z\d+.-]*:/u.test(normalized)) {
124
+ throw staticIdentityError(
125
+ `${label} must stay relative to the ${boundaryLabel}; received "${value}".`,
126
+ filePath,
127
+ 'invalid-path',
128
+ { value }
129
+ );
130
+ }
131
+ if (normalized === '..' || normalized.startsWith('../')) {
132
+ throw staticIdentityError(
133
+ `${label} must not escape the ${boundaryLabel}; received "${value}".`,
134
+ filePath,
135
+ 'invalid-path',
136
+ { value }
137
+ );
138
+ }
139
+ const relativePath = normalized.replace(/^\.\//u, '').replace(/\/+$/u, '');
140
+ if (relativePath === '' || relativePath === '.') {
141
+ if (allowCurrentDirectory) {
142
+ return '.';
143
+ }
144
+ throw staticIdentityError(
145
+ `${label} must name a module entry within the ${boundaryLabel}; received "${value}".`,
146
+ filePath,
147
+ 'invalid-path',
148
+ { value }
149
+ );
150
+ }
151
+ return relativePath;
152
+ };
153
+
154
+ const normalizeWorkspace = (
155
+ value: unknown,
156
+ filePath: string,
157
+ rootDir: string,
158
+ collectionBoundaryDir: string
159
+ ): Pick<ReadTrailsProjectIdentityResult, 'apps' | 'workspace'> => {
160
+ if (!isRecord(value) || !isRecord(value['apps'])) {
161
+ throw staticIdentityError(
162
+ `workspace.apps must be an object keyed by stable app ID in ${filePath}.`,
163
+ filePath,
164
+ 'invalid-shape'
165
+ );
166
+ }
167
+ const workspaceFields = Object.keys(value).filter((key) => key !== 'apps');
168
+ if (workspaceFields.length > 0) {
169
+ throw staticIdentityError(
170
+ `workspace contains unsupported fields: ${workspaceFields.join(', ')}. Project identity currently owns only apps.`,
171
+ filePath,
172
+ 'invalid-shape',
173
+ { fields: workspaceFields }
174
+ );
175
+ }
176
+
177
+ const rawApps = value['apps'];
178
+ const authoredApps = new Map<string, TrailsWorkspaceAppConfig>();
179
+ const roots = new Map<
180
+ string,
181
+ { readonly id: string; readonly root: string }
182
+ >();
183
+ const canonicalWorkspaceRoot = canonicalBoundaryPath(rootDir);
184
+ const apps = Object.keys(rawApps)
185
+ .toSorted(compareStrings)
186
+ .map((id): ResolvedTrailsWorkspaceApp => {
187
+ if (id.trim() === '') {
188
+ throw staticIdentityError(
189
+ `workspace.apps contains an empty app ID in ${filePath}.`,
190
+ filePath,
191
+ 'invalid-app'
192
+ );
193
+ }
194
+ const rawApp = rawApps[id];
195
+ if (!isRecord(rawApp)) {
196
+ throw staticIdentityError(
197
+ `workspace.apps.${id} must be an object with a project-relative root.`,
198
+ filePath,
199
+ 'invalid-app',
200
+ { appId: id }
201
+ );
202
+ }
203
+ const unknownKeys = Object.keys(rawApp).filter(
204
+ (key) => key !== 'entry' && key !== 'root'
205
+ );
206
+ if (unknownKeys.length > 0) {
207
+ throw staticIdentityError(
208
+ `workspace.apps.${id} contains unsupported fields: ${unknownKeys.join(', ')}. Use only root and the optional entry override.`,
209
+ filePath,
210
+ 'invalid-app',
211
+ { appId: id, fields: unknownKeys }
212
+ );
213
+ }
214
+
215
+ const appRoot = normalizeProjectRelativePath(
216
+ rawApp['root'],
217
+ `workspace.apps.${id}.root`,
218
+ filePath,
219
+ 'project root',
220
+ true
221
+ );
222
+ const resolvedAppRoot = resolve(rootDir, appRoot);
223
+ const canonicalAppRoot = canonicalBoundaryPath(resolvedAppRoot);
224
+ if (!isWithinBoundary(canonicalWorkspaceRoot, canonicalAppRoot)) {
225
+ throw staticIdentityError(
226
+ `workspace.apps.${id}.root resolves outside the workspace trust boundary: "${appRoot}".`,
227
+ filePath,
228
+ 'invalid-path',
229
+ { appId: id, root: appRoot }
230
+ );
231
+ }
232
+ const explicitEntry =
233
+ rawApp['entry'] === undefined
234
+ ? undefined
235
+ : normalizeProjectRelativePath(
236
+ rawApp['entry'],
237
+ `workspace.apps.${id}.entry`,
238
+ filePath,
239
+ 'app root'
240
+ );
241
+ const entry = explicitEntry ?? trailsAppEntryRelativePath;
242
+ const modulePath = posix.join(appRoot, entry);
243
+ const resolvedEntryPath = resolve(rootDir, modulePath);
244
+ const canonicalEntryPath = canonicalBoundaryPath(resolvedEntryPath);
245
+ const relevantCollectionBoundaries = collectConfigBoundariesThroughPaths(
246
+ collectionBoundaryDir,
247
+ [resolvedAppRoot, resolvedEntryPath]
248
+ );
249
+ const appCollectionEdge = relevantCollectionBoundaries.find((boundary) =>
250
+ isWithinBoundary(boundary.canonicalPath, canonicalAppRoot)
251
+ );
252
+ if (appCollectionEdge !== undefined) {
253
+ throw staticIdentityError(
254
+ `workspace.apps.${id}.root traverses a ${appCollectionEdge.reason} collection edge at "${appCollectionEdge.path}": "${appRoot}".`,
255
+ filePath,
256
+ 'invalid-path',
257
+ {
258
+ appId: id,
259
+ boundaryPath: appCollectionEdge.path,
260
+ boundaryReason: appCollectionEdge.reason,
261
+ root: appRoot,
262
+ }
263
+ );
264
+ }
265
+ const existingRootOwner = roots.get(canonicalAppRoot);
266
+ if (existingRootOwner !== undefined) {
267
+ throw staticIdentityError(
268
+ `workspace.apps.${id}.root resolves to "${appRoot}", which is already owned by "${existingRootOwner.id}". App roots must be unique.`,
269
+ filePath,
270
+ 'invalid-app',
271
+ {
272
+ appId: id,
273
+ conflictingAppId: existingRootOwner.id,
274
+ root: appRoot,
275
+ }
276
+ );
277
+ }
278
+ const overlappingRootOwner = [...roots.entries()].find(
279
+ ([canonicalRoot]) =>
280
+ isWithinBoundary(canonicalRoot, canonicalAppRoot) ||
281
+ isWithinBoundary(canonicalAppRoot, canonicalRoot)
282
+ );
283
+ if (overlappingRootOwner !== undefined) {
284
+ const [, owner] = overlappingRootOwner;
285
+ throw staticIdentityError(
286
+ `workspace.apps.${id}.root resolves to "${appRoot}", which overlaps root "${owner.root}" owned by "${owner.id}". App roots must not overlap.`,
287
+ filePath,
288
+ 'invalid-app',
289
+ {
290
+ appId: id,
291
+ conflictingAppId: owner.id,
292
+ conflictingRoot: owner.root,
293
+ root: appRoot,
294
+ }
295
+ );
296
+ }
297
+ roots.set(canonicalAppRoot, { id, root: appRoot });
298
+ if (!isWithinBoundary(canonicalAppRoot, canonicalEntryPath)) {
299
+ throw staticIdentityError(
300
+ `workspace.apps.${id}.entry resolves outside its app root trust boundary: "${entry}".`,
301
+ filePath,
302
+ 'invalid-path',
303
+ { appId: id, entry, root: appRoot }
304
+ );
305
+ }
306
+ const entryCollectionEdge = relevantCollectionBoundaries.find(
307
+ (boundary) =>
308
+ isWithinBoundary(boundary.canonicalPath, canonicalEntryPath)
309
+ );
310
+ if (entryCollectionEdge !== undefined) {
311
+ throw staticIdentityError(
312
+ `workspace.apps.${id}.entry traverses a ${entryCollectionEdge.reason} collection edge at "${entryCollectionEdge.path}": "${entry}".`,
313
+ filePath,
314
+ 'invalid-path',
315
+ {
316
+ appId: id,
317
+ boundaryPath: entryCollectionEdge.path,
318
+ boundaryReason: entryCollectionEdge.reason,
319
+ entry,
320
+ root: appRoot,
321
+ }
322
+ );
323
+ }
324
+ let appRootIsDirectory = false;
325
+ try {
326
+ appRootIsDirectory = statSync(resolvedAppRoot).isDirectory();
327
+ } catch {
328
+ appRootIsDirectory = false;
329
+ }
330
+ if (!appRootIsDirectory) {
331
+ throw staticIdentityError(
332
+ `workspace.apps.${id}.root must resolve to an existing directory within the workspace: "${appRoot}".`,
333
+ filePath,
334
+ 'invalid-path',
335
+ { appId: id, root: appRoot }
336
+ );
337
+ }
338
+ authoredApps.set(id, {
339
+ ...(explicitEntry === undefined ? {} : { entry: explicitEntry }),
340
+ root: appRoot,
341
+ });
342
+ return {
343
+ entry,
344
+ entryPath: resolvedEntryPath,
345
+ entrySource: explicitEntry === undefined ? 'convention' : 'explicit',
346
+ id,
347
+ modulePath,
348
+ root: appRoot,
349
+ rootDir: resolvedAppRoot,
350
+ };
351
+ });
352
+
353
+ return { apps, workspace: { apps: Object.fromEntries(authoredApps) } };
354
+ };
355
+
356
+ const readIdentityAtConfigPath = async (
357
+ configPath: string,
358
+ resolvedBoundary: string,
359
+ canonicalBoundary: string
360
+ ): Promise<ReadTrailsProjectIdentityResult> => {
361
+ const canonicalConfigPath = canonicalBoundaryPath(configPath);
362
+ const canonicalConfigDirectory = dirname(canonicalConfigPath);
363
+ if (!isWithinBoundary(canonicalBoundary, canonicalConfigDirectory)) {
364
+ throw new ValidationError(
365
+ `Trails config file "${configPath}" is outside discovery boundary "${resolvedBoundary}".`,
366
+ { context: { boundaryDir: resolvedBoundary, configPath } }
367
+ );
368
+ }
369
+ if (!existsSync(configPath)) {
370
+ throw new NotFoundError(`Trails config file not found: ${configPath}`, {
371
+ context: { path: configPath },
372
+ });
373
+ }
374
+ if (
375
+ localConfigFileNames.has(basename(configPath)) ||
376
+ localConfigFileNames.has(basename(canonicalConfigPath))
377
+ ) {
378
+ throw new ValidationError(
379
+ `Trails local config override "${configPath}" cannot establish static project identity. Local overrides are deployment input and never own workspace.apps.`,
380
+ { context: { canonicalConfigPath, configPath } }
381
+ );
382
+ }
383
+ const rootDir = resolve(
384
+ resolvedBoundary,
385
+ relative(canonicalBoundary, canonicalConfigDirectory)
386
+ );
387
+ const config = await parseTrailsProjectConfigFile(canonicalConfigPath);
388
+ if (isRecord(config) && config['workspace'] !== undefined) {
389
+ return {
390
+ ...normalizeWorkspace(
391
+ config['workspace'],
392
+ configPath,
393
+ rootDir,
394
+ resolvedBoundary
395
+ ),
396
+ configPath,
397
+ rootDir,
398
+ };
399
+ }
400
+ return { apps: [], configPath, rootDir };
401
+ };
402
+
403
+ /**
404
+ * Read source-static workspace identity without importing a config module.
405
+ *
406
+ * Discovery walks authored config markers independently of app-local lock
407
+ * markers, so a nested app CWD still resolves the owning workspace config.
408
+ */
409
+ export const readTrailsProjectIdentity = async (
410
+ options: ReadTrailsProjectIdentityOptions
411
+ ): Promise<ReadTrailsProjectIdentityResult> => {
412
+ if (
413
+ options === undefined ||
414
+ typeof options.boundaryDir !== 'string' ||
415
+ options.boundaryDir.trim() === ''
416
+ ) {
417
+ throw new ValidationError(
418
+ 'Static project identity requires an explicit collection boundaryDir.'
419
+ );
420
+ }
421
+ const { boundaryDir, configPath, startDir = process.cwd() } = options;
422
+ const resolvedStart = resolve(startDir);
423
+ const resolvedBoundary = resolve(boundaryDir);
424
+ const canonicalBoundary = canonicalBoundaryPath(resolvedBoundary);
425
+ let selectedPaths: readonly string[];
426
+ if (configPath === undefined) {
427
+ selectedPaths = findConfigPathsThroughBoundary(
428
+ resolvedStart,
429
+ resolvedBoundary
430
+ );
431
+ } else {
432
+ const explicitPath = resolve(resolvedStart, configPath);
433
+ const canonicalExplicitDirectory = dirname(
434
+ canonicalBoundaryPath(explicitPath)
435
+ );
436
+ if (!isWithinBoundary(canonicalBoundary, canonicalExplicitDirectory)) {
437
+ throw new ValidationError(
438
+ `Trails config file "${explicitPath}" is outside discovery boundary "${resolvedBoundary}".`,
439
+ { context: { boundaryDir: resolvedBoundary, configPath: explicitPath } }
440
+ );
441
+ }
442
+ const lexicalTargetDirectory = resolve(
443
+ resolvedBoundary,
444
+ relative(canonicalBoundary, canonicalExplicitDirectory)
445
+ );
446
+ const throughBoundary = combineConfigPaths(
447
+ findConfigPathsThroughBoundary(lexicalTargetDirectory, resolvedBoundary),
448
+ [explicitPath]
449
+ );
450
+ const canonicalExplicitPath = canonicalBoundaryPath(explicitPath);
451
+ selectedPaths = [
452
+ explicitPath,
453
+ ...throughBoundary.filter(
454
+ (path) => canonicalBoundaryPath(path) !== canonicalExplicitPath
455
+ ),
456
+ ];
457
+ }
458
+ const selectedIdentities: ReadTrailsProjectIdentityResult[] = [];
459
+ for (const selectedPath of selectedPaths) {
460
+ selectedIdentities.push(
461
+ await readIdentityAtConfigPath(
462
+ selectedPath,
463
+ resolvedBoundary,
464
+ canonicalBoundary
465
+ )
466
+ );
467
+ }
468
+ const selectedWorkspace = selectedIdentities.find(
469
+ (identity) => identity.workspace !== undefined
470
+ );
471
+ if (selectedWorkspace !== undefined) {
472
+ const selectedRoot = canonicalBoundaryPath(selectedWorkspace.rootDir);
473
+ const selectedAncestors = selectedPaths.filter((selectedPath) => {
474
+ const candidateRoot = dirname(canonicalBoundaryPath(selectedPath));
475
+ return isWithinBoundary(candidateRoot, selectedRoot);
476
+ });
477
+ const relevantPaths = combineConfigPaths(
478
+ collectConfigPathsWithinBoundary(
479
+ selectedWorkspace.rootDir,
480
+ selectedWorkspace.apps.map((app) => app.rootDir),
481
+ resolvedBoundary
482
+ ),
483
+ selectedAncestors
484
+ );
485
+ const workspaceResults: ReadTrailsProjectIdentityResult[] = [];
486
+ for (const relevantPath of relevantPaths) {
487
+ const identity = await readIdentityAtConfigPath(
488
+ relevantPath,
489
+ resolvedBoundary,
490
+ canonicalBoundary
491
+ );
492
+ if (identity.workspace !== undefined) {
493
+ workspaceResults.push(identity);
494
+ }
495
+ }
496
+ const selectedConfigPath = canonicalBoundaryPath(
497
+ selectedWorkspace.configPath as string
498
+ );
499
+ const validatedSelectedWorkspace = workspaceResults.find(
500
+ (result) =>
501
+ canonicalBoundaryPath(result.configPath as string) ===
502
+ selectedConfigPath
503
+ );
504
+ if (validatedSelectedWorkspace === undefined) {
505
+ throw new ValidationError(
506
+ `Unable to validate selected Trails workspace config "${selectedWorkspace.configPath}" inside its collection boundary.`,
507
+ {
508
+ context: {
509
+ configPath: selectedWorkspace.configPath,
510
+ rootDir: selectedWorkspace.rootDir,
511
+ },
512
+ }
513
+ );
514
+ }
515
+ const overlappingWorkspaces = workspaceResults.filter((result) => {
516
+ if (
517
+ canonicalBoundaryPath(result.configPath as string) ===
518
+ selectedConfigPath
519
+ ) {
520
+ return false;
521
+ }
522
+ const candidateRoot = canonicalBoundaryPath(result.rootDir);
523
+ return (
524
+ isWithinBoundary(selectedRoot, candidateRoot) ||
525
+ isWithinBoundary(candidateRoot, selectedRoot)
526
+ );
527
+ });
528
+ if (overlappingWorkspaces.length === 0) {
529
+ return validatedSelectedWorkspace;
530
+ }
531
+ const conflictingWorkspaces = [
532
+ validatedSelectedWorkspace,
533
+ ...overlappingWorkspaces,
534
+ ];
535
+ const roots = conflictingWorkspaces.map((result) => result.rootDir);
536
+ throw new ValidationError(
537
+ `Nested Trails workspaces are not supported. Found workspace identity at: ${roots.join(', ')}. Keep one workspace.apps owner within the collection boundary.`,
538
+ {
539
+ context: {
540
+ configPaths: conflictingWorkspaces.map((result) => result.configPath),
541
+ roots,
542
+ },
543
+ }
544
+ );
545
+ }
546
+
547
+ const [nearest] = selectedIdentities;
548
+ if (nearest === undefined) {
549
+ return { apps: [], rootDir: resolvedStart };
550
+ }
551
+ return nearest;
552
+ };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Shared Zod introspection helpers used by config resolution, doctor,
3
+ * describe, explain, and collect modules.
4
+ */
5
+
6
+ import type { z } from 'zod';
7
+
8
+ /** Extract the Zod def record from any ZodType. */
9
+ export const zodDef = (schema: z.ZodType): Record<string, unknown> =>
10
+ schema.def as unknown as Record<string, unknown>;
11
+
12
+ /** Wrapper types that preserve object traversal shape. */
13
+ const TRAVERSAL_WRAPPER_TYPES = new Set(['optional', 'default', 'nullable']);
14
+
15
+ /** Wrapper types env overlay can inspect before coercing a string value. */
16
+ const ENV_WRAPPER_TYPES = new Set([
17
+ ...TRAVERSAL_WRAPPER_TYPES,
18
+ 'catch',
19
+ 'nonoptional',
20
+ 'prefault',
21
+ 'readonly',
22
+ ]);
23
+
24
+ /** Unwrap through selected wrappers to find the base schema. */
25
+ const unwrapWith = (
26
+ schema: z.ZodType,
27
+ wrapperTypes: ReadonlySet<string>
28
+ ): z.ZodType => {
29
+ let current = schema;
30
+ for (let depth = 0; depth < 10; depth += 1) {
31
+ const def = zodDef(current);
32
+ if (!wrapperTypes.has(def['type'] as string)) {
33
+ return current;
34
+ }
35
+ const inner = def['innerType'] as z.ZodType | undefined;
36
+ if (!inner) {
37
+ return current;
38
+ }
39
+ current = inner;
40
+ }
41
+ return current;
42
+ };
43
+
44
+ /** Unwrap through shape-preserving wrappers to find the base schema. */
45
+ export const unwrapToBase = (schema: z.ZodType): z.ZodType =>
46
+ unwrapWith(schema, TRAVERSAL_WRAPPER_TYPES);
47
+
48
+ /** Unwrap through env-coercion wrappers to find the env target schema. */
49
+ const unwrapToEnvBase = (schema: z.ZodType): z.ZodType =>
50
+ unwrapWith(schema, ENV_WRAPPER_TYPES);
51
+
52
+ /** Check if a schema is (or wraps) a ZodObject by inspecting its def. */
53
+ export const isZodObject = (
54
+ schema: z.ZodType
55
+ ): schema is z.ZodObject<Record<string, z.ZodType>> => {
56
+ const def = zodDef(unwrapToBase(schema));
57
+ return def['type'] === 'object' && 'shape' in def;
58
+ };
59
+
60
+ /** Container types an env string should not replace wholesale. */
61
+ const CONTAINER_TYPES = new Set([
62
+ 'object',
63
+ 'array',
64
+ 'tuple',
65
+ 'record',
66
+ 'map',
67
+ 'set',
68
+ ]);
69
+
70
+ /** Check whether a schema is a container shape after unwrapping defaults. */
71
+ export const isZodContainer = (schema: z.ZodType): boolean => {
72
+ const def = zodDef(unwrapToEnvBase(schema));
73
+ return CONTAINER_TYPES.has(def['type'] as string);
74
+ };
75
+
76
+ /** Boolean string values we accept from environment variables. */
77
+ const BOOL_TRUE = new Set(['true', '1']);
78
+ const BOOL_FALSE = new Set(['false', '0']);
79
+
80
+ /** Primitive type names we can coerce env strings into. */
81
+ const PRIMITIVE_TYPES = new Set(['number', 'boolean', 'string']);
82
+
83
+ /** Resolve the primitive base type name after unwrapping defaults. */
84
+ const resolvePrimitiveBaseTypeName = (
85
+ schema: z.ZodType
86
+ ): string | undefined => {
87
+ const def = zodDef(unwrapToEnvBase(schema));
88
+ const typeName = def['type'] as string | undefined;
89
+ return typeName && PRIMITIVE_TYPES.has(typeName) ? typeName : undefined;
90
+ };
91
+
92
+ /** Coerce a boolean env string. Returns the original string if unrecognized. */
93
+ const coerceBooleanEnv = (raw: string): unknown => {
94
+ if (BOOL_TRUE.has(raw)) {
95
+ return true;
96
+ }
97
+ if (BOOL_FALSE.has(raw)) {
98
+ return false;
99
+ }
100
+ return raw;
101
+ };
102
+
103
+ /** Coerce env var lookup table keyed by base type name. */
104
+ const ENV_COERCERS: Record<string, (raw: string) => unknown> = {
105
+ boolean: coerceBooleanEnv,
106
+ number: (raw: string) => {
107
+ const n = Number(raw);
108
+ return Number.isNaN(n) ? raw : n;
109
+ },
110
+ };
111
+
112
+ /** Coerce a string env value to the type expected by the schema field. */
113
+ export const coerceEnvValue = (raw: string, schema: z.ZodType): unknown => {
114
+ const typeName = resolvePrimitiveBaseTypeName(schema);
115
+ const coercer = typeName ? ENV_COERCERS[typeName] : undefined;
116
+ return coercer ? coercer(raw) : raw;
117
+ };
118
+
119
+ /** Resolve a schema at a dot-separated path through nested object shapes. */
120
+ export const getSchemaAtPath = (
121
+ schema: z.ZodType,
122
+ path: string
123
+ ): z.ZodType | undefined => {
124
+ let current = schema;
125
+ for (const part of path.split('.')) {
126
+ const base = unwrapToBase(current);
127
+ const shape = zodDef(base)['shape'] as
128
+ | Record<string, z.ZodType>
129
+ | undefined;
130
+ const next = shape?.[part];
131
+ if (!next) {
132
+ return undefined;
133
+ }
134
+ current = next;
135
+ }
136
+ return current;
137
+ };
138
+
139
+ /** Read a value at a dot-separated path from a plain object. */
140
+ export const getAtPath = (
141
+ obj: Record<string, unknown>,
142
+ path: string
143
+ ): unknown => {
144
+ let current: unknown = obj;
145
+ for (const part of path.split('.')) {
146
+ if (typeof current !== 'object' || current === null) {
147
+ return undefined;
148
+ }
149
+ current = (current as Record<string, unknown>)[part];
150
+ }
151
+ return current;
152
+ };