From c6cbeb478238763340036d0f5d2754350d389292 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 13 Nov 2026 00:17:06 +0000 Subject: [PATCH 1/5] Add timeZone data type and rename simple-object to simpleObject --- .bumpy/timezone-data-type.md | 6 ++ .../src/content/docs/reference/data-types.mdx | 17 ++++-- .../varlock/src/env-graph/lib/data-types.ts | 49 ++++++++++++++- .../varlock/src/env-graph/lib/env-graph.ts | 5 +- .../src/env-graph/lib/type-decorator.ts | 17 +++++- .../test/composite-serialization.test.ts | 6 +- .../src/env-graph/test/data-types.test.ts | 59 +++++++++++++++++++ .../env-graph/test/type-generation.test.ts | 2 +- .../env-graph/test/type-generation/helpers.ts | 2 +- .../vscode-plugin/src/diagnostics-core.ts | 7 +++ .../vscode-plugin/src/intellisense-catalog.ts | 10 +++- 11 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 .bumpy/timezone-data-type.md diff --git a/.bumpy/timezone-data-type.md b/.bumpy/timezone-data-type.md new file mode 100644 index 000000000..15af90fa3 --- /dev/null +++ b/.bumpy/timezone-data-type.md @@ -0,0 +1,6 @@ +--- +varlock: minor +env-spec-language: patch +--- + +Add built-in `timeZone` data type that validates IANA time zone names. Rename the `simple-object` data type to `simpleObject` so all built-in type names are camelCase (`simple-object` still works but logs a deprecation warning). diff --git a/packages/varlock-website/src/content/docs/reference/data-types.mdx b/packages/varlock-website/src/content/docs/reference/data-types.mdx index c6b6ceb3f..c08b62982 100644 --- a/packages/varlock-website/src/content/docs/reference/data-types.mdx +++ b/packages/varlock-website/src/content/docs/reference/data-types.mdx @@ -300,6 +300,15 @@ MY_DATE=2024-03-20T15:30:00Z ``` +
+### `timeZone` +Checks for a valid [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, using the time zone database built into the JS runtime. Names are case-sensitive, and UTC offsets like `+05:00` are rejected. +```env-spec +# @type=timeZone +APP_TIME_ZONE=Europe/Madrid +``` +
+
### `uuid` Checks for valid [UUID](https://en.wikipedia.org/wiki/UUID) (versions 1-8 per [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562), including `NIL` and `MAX`). @@ -327,10 +336,10 @@ MY_HASH=d41d8cd98f00b204e9800998ecf8427e
-### `simple-object` -Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values. +### `simpleObject` +Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values. Formerly named `simple-object`, which still works but is deprecated. ```env-spec -# @type=simple-object +# @type=simpleObject MY_OBJECT={"key": "value"} ```
@@ -430,6 +439,6 @@ Validation errors are reported per entry (`"apac" is not a valid key - ...`). In application code, `ENV.ENDPOINTS` is typed as `Record` (enum-constrained keys narrow further). In `process.env` the value is JSON. -An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simple-object` type behaves the same as a bare `record`. +An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simpleObject` type behaves the same as a bare `record`. diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index 3f1aa4fc6..87fd020ed 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -795,7 +795,7 @@ const DomainDataType = createEnvGraphDataType( const SimpleObjectDataType = createEnvGraphDataType({ - name: 'simple-object', + name: 'simpleObject', icon: 'tabler:code-dots', // curly brackets with nothing inside coercedType: 'object', coerce(val) { @@ -973,6 +973,44 @@ const IsoDateDataType = createEnvGraphDataType({ }); +// Validation leans on the runtime's own tz database via `Intl`, so we ship no zone list. +// Engines accept a few things that are not IANA names (UTC offsets like `+05:00`) and match +// names case-insensitively, while most consumers (e.g. the `TZ` env var on Linux) are +// case-sensitive, so we check both explicitly. +let lowercaseTimeZoneLookup: Map | undefined; +function findTimeZoneByCase(val: string): string | undefined { + if (!lowercaseTimeZoneLookup) { + lowercaseTimeZoneLookup = new Map(); + const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; + for (const zone of zones ?? []) lowercaseTimeZoneLookup.set(zone.toLowerCase(), zone); + } + return lowercaseTimeZoneLookup.get(val.toLowerCase()); +} +const TimeZoneDataType = createEnvGraphDataType({ + name: 'timeZone', + icon: 'mdi:map-clock-outline', + typeDescription: 'IANA time zone name (e.g. `Europe/Madrid`)', + validate(val) { + if (/^[+-]\d/.test(val)) { + return new ValidationError('Value must be an IANA time zone name, not a UTC offset'); + } + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; + } catch { + return new ValidationError(`"${val}" is not a known IANA time zone`); + } + if (resolved !== val) { + const correctCase = findTimeZoneByCase(val) + ?? (resolved.toLowerCase() === val.toLowerCase() ? resolved : undefined); + if (correctCase && correctCase !== val) { + return new ValidationError(`Time zone names are case-sensitive, did you mean "${correctCase}"?`); + } + } + return true; + }, +}); + // RFC 9562 versions 1-8 (captures the version digit), plus the special NIL and MAX UUIDs const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const NIL_UUID = '00000000-0000-0000-0000-000000000000'; @@ -1384,6 +1422,14 @@ const RecordDataType = createEnvGraphDataType((settings?: RecordDataTypeSettings }; }); +/** + * Old type names still accepted (with a deprecation warning) and the name that replaced them. + * TODO: drop in the next major. + */ +export const DEPRECATED_DATA_TYPE_ALIASES: Record = { + 'simple-object': 'simpleObject', +}; + export const BaseDataTypes: Array = [ StringDataType, NumberDataType, @@ -1397,6 +1443,7 @@ export const BaseDataTypes: Array = [ PortDataType, SemverDataType, IsoDateDataType, + TimeZoneDataType, UuidDataType, Md5DataType, DurationDataType, diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index c6a89a42d..5a3296279 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -7,7 +7,7 @@ import { EnvGraphDataSource, FileBasedDataSource, ImportAliasSource } from './da import { computeFilteredKeys, type ParsedItemFilter } from './item-filter'; import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver'; -import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types'; +import { BaseDataTypes, DEPRECATED_DATA_TYPE_ALIASES, type EnvGraphDataTypeFactory } from './data-types'; import { findGraphCycles, getTransitiveDeps, type GraphAdjacencyList } from './graph-utils'; import { ResolutionError, SchemaError, ValidationError } from './errors'; import { @@ -491,6 +491,9 @@ export class EnvGraph { for (const dataType of BaseDataTypes) { this.registerDataType(dataType); } + for (const [alias, name] of Object.entries(DEPRECATED_DATA_TYPE_ALIASES)) { + this.dataTypesRegistry[alias] = this.dataTypesRegistry[name]; + } // register base resolvers (concat, ref, exec, etc) for (const resolverClass of BaseResolvers) { this.registerResolver(resolverClass); diff --git a/packages/varlock/src/env-graph/lib/type-decorator.ts b/packages/varlock/src/env-graph/lib/type-decorator.ts index 58fd90ee4..89ac8ca38 100644 --- a/packages/varlock/src/env-graph/lib/type-decorator.ts +++ b/packages/varlock/src/env-graph/lib/type-decorator.ts @@ -7,7 +7,9 @@ import { ParsedEnvSpecStaticValue, type ParsedEnvSpecDecorator, } from '@env-spec/parser'; -import type { CoercedType, EnvGraphDataType, EnvGraphDataTypeFactory } from './data-types'; +import { + DEPRECATED_DATA_TYPE_ALIASES, type CoercedType, type EnvGraphDataType, type EnvGraphDataTypeFactory, +} from './data-types'; import { assertUnwrappedRegexSource, convertParsedValueToResolvers, deprecatedRegexStringWarning, type Resolver, @@ -98,10 +100,19 @@ function unknownDataTypeError(name: string, context?: string): SchemaError { return new SchemaError(`${context ? `${context} - ` : ''}unknown data type: ${name}`, { // `object` is the natural guess for what varlock calls `record` (reserved for a // possible future per-key shape type) - ...name === 'object' ? { tip: 'use record(...) for typed key/value maps, or simple-object for untyped JSON objects' } : {}, + ...name === 'object' ? { tip: 'use record(...) for typed key/value maps, or simpleObject for untyped JSON objects' } : {}, }); } +function warnIfDeprecatedTypeName(ctx: TypeSpecContext, name: string, context?: string) { + const replacement = DEPRECATED_DATA_TYPE_ALIASES[name]; + if (!replacement) return; + pushWarningOnce(ctx.warnings, new SchemaError( + `${context ? `${context} - ` : ''}data type "${name}" is deprecated, use "${replacement}" instead`, + { isWarning: true, tip: 'it will be removed in the next major version' }, + )); +} + function typeSpecDisplayName(node: TypeSpecNode): string { return node instanceof ParsedEnvSpecFunctionCall ? node.name : String(node.value); } @@ -191,6 +202,7 @@ function buildBareTypePlan( context?: string, ): TypeSpecPlan { if (!(name in ctx.registry)) throw unknownDataTypeError(name, context); + warnIfDeprecatedTypeName(ctx, name, context); if (name === 'enum') throw emptyEnumError(context ?? '@type=enum'); return { deferred: [], build: () => ctx.registry[name]() }; } @@ -505,6 +517,7 @@ function buildTypeCallPlan( ): TypeSpecPlan { const name = fnCall.name; if (!(name in ctx.registry)) throw unknownDataTypeError(name, context); + warnIfDeprecatedTypeName(ctx, name, context); if (name === 'array') return buildArrayTypePlan(ctx, fnCall, context); if (name === 'record') return buildRecordTypePlan(ctx, fnCall, context); diff --git a/packages/varlock/src/env-graph/test/composite-serialization.test.ts b/packages/varlock/src/env-graph/test/composite-serialization.test.ts index 4ba172782..cf84a75e6 100644 --- a/packages/varlock/src/env-graph/test/composite-serialization.test.ts +++ b/packages/varlock/src/env-graph/test/composite-serialization.test.ts @@ -187,10 +187,10 @@ describe('runtime re-injection of composite values (initVarlockEnv)', () => { }); }); -describe('simple-object serialization (issue #900)', () => { - it('simple-object values serialize as JSON, not "[object Object]"', async () => { +describe('simpleObject serialization (issue #900)', () => { + it('simpleObject values serialize as JSON, not "[object Object]"', async () => { const g = await loadAndResolve(outdent` - # @type=simple-object + # @type=simpleObject MY_OBJECT='{"key": "value"}' `); expect(g.configSchema.MY_OBJECT.resolvedValue).toEqual({ key: 'value' }); diff --git a/packages/varlock/src/env-graph/test/data-types.test.ts b/packages/varlock/src/env-graph/test/data-types.test.ts index d42e14fe8..93ac9f522 100644 --- a/packages/varlock/src/env-graph/test/data-types.test.ts +++ b/packages/varlock/src/env-graph/test/data-types.test.ts @@ -956,6 +956,65 @@ describe('md5 data type', () => { }); }); +describe('deprecated data type names', () => { + it.each([ + '@type=simple-object', + '@type=array(simple-object)', + ])('%s still works but warns', async (decorator) => { + const g = await loadAndResolve(outdent` + # ${decorator} + OBJ='${decorator.includes('array') ? '[{"a": 1}]' : '{"a": 1}'}' + `); + const item = g.configSchema.OBJ; + expect(item.isValid).toBe(true); + expect(item.dataType?.name).toBe(decorator.includes('array') ? 'array' : 'simpleObject'); + const warnings = item.errors.filter((e) => (e as any).isWarning); + expect(warnings.map((w) => w.message).join('\n')).toContain('use "simpleObject" instead'); + }); +}); + +describe('timeZone data type', () => { + it.each([ + 'Europe/Madrid', + 'UTC', + 'America/Argentina/Buenos_Aires', + 'America/Buenos_Aires', // legacy alias + 'Etc/GMT+5', + ])('accepts %s', async (value) => { + const g = await loadAndResolve(outdent` + # @type=timeZone + TZ=${value} + `); + expect(g.configSchema.TZ.isValid).toBe(true); + expect(g.configSchema.TZ.resolvedValue).toBe(value); + }); + + it.each([ + ['typo', 'Europe/Madird'], + ['UTC offset', '+05:00'], + ['compact UTC offset', '-0800'], + ['garbage', 'not a zone'], + ])('rejects %s', async (_label, value) => { + const g = await loadAndResolve(outdent` + # @type=timeZone + TZ="${value}" + `); + expect(g.configSchema.TZ.isValid).toBe(false); + }); + + it.each([ + ['europe/madrid', 'Europe/Madrid'], + ['utc', 'UTC'], + ])('rejects wrong casing (%s) and suggests the correct name', async (value, suggestion) => { + const g = await loadAndResolve(outdent` + # @type=timeZone + TZ=${value} + `); + expect(g.configSchema.TZ.isValid).toBe(false); + expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`"${suggestion}"`); + }); +}); + describe('domain data type', () => { it('accepts a basic domain', async () => { const g = await loadAndResolve(outdent` diff --git a/packages/varlock/src/env-graph/test/type-generation.test.ts b/packages/varlock/src/env-graph/test/type-generation.test.ts index b3ebc8951..5f460a6ec 100644 --- a/packages/varlock/src/env-graph/test/type-generation.test.ts +++ b/packages/varlock/src/env-graph/test/type-generation.test.ts @@ -1396,7 +1396,7 @@ describe('type generation', () => { FLAG=true # @type=boolean @public # @type=enum(dev, staging, prod) APP_ENV=dev # @public - # @type=simple-object + # @type=simpleObject CONFIG={} # @public `, }); diff --git a/packages/varlock/src/env-graph/test/type-generation/helpers.ts b/packages/varlock/src/env-graph/test/type-generation/helpers.ts index cf4059c07..213209033 100644 --- a/packages/varlock/src/env-graph/test/type-generation/helpers.ts +++ b/packages/varlock/src/env-graph/test/type-generation/helpers.ts @@ -20,7 +20,7 @@ export const NON_STRING_TYPE_FIXTURE = outdent` DEBUG=false # @optional @public # @type=enum(dev, staging, prod) APP_ENV=dev # @required @public - # @type=simple-object + # @type=simpleObject CONFIG={} # @optional @public API_KEY= # @required @sensitive `; diff --git a/packages/vscode-plugin/src/diagnostics-core.ts b/packages/vscode-plugin/src/diagnostics-core.ts index 3fa00d0e5..45cabc2ed 100644 --- a/packages/vscode-plugin/src/diagnostics-core.ts +++ b/packages/vscode-plugin/src/diagnostics-core.ts @@ -676,6 +676,13 @@ export function validateStaticValue(typeInfo: TypeInfo, value: string) { return /^\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+-Z]*)?$/.test(value) && !Number.isNaN(Date.parse(value)) ? undefined : 'Value must be a valid ISO date.'; + case 'timeZone': + try { + Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions(); + return /^[+-]\d/.test(value) ? 'Value must be an IANA time zone name, not a UTC offset.' : undefined; + } catch { + return 'Value must be a known IANA time zone.'; + } case 'uuid': { const version = typeInfo.options.version; const detectedVersion = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(value)?.[1]; diff --git a/packages/vscode-plugin/src/intellisense-catalog.ts b/packages/vscode-plugin/src/intellisense-catalog.ts index c443c3017..efafd67ef 100644 --- a/packages/vscode-plugin/src/intellisense-catalog.ts +++ b/packages/vscode-plugin/src/intellisense-catalog.ts @@ -370,10 +370,10 @@ export const DATA_TYPES: Array = [ ], }, { - name: 'simple-object', + name: 'simpleObject', summary: 'JSON-like object value.', documentation: 'Coerces plain objects or JSON strings into objects.', - insertText: 'simple-object', + insertText: 'simpleObject', }, { name: 'enum', @@ -446,6 +446,12 @@ export const DATA_TYPES: Array = [ documentation: 'Supports date strings with optional time and milliseconds.', insertText: 'isoDate', }, + { + name: 'timeZone', + summary: 'IANA time zone name.', + documentation: 'Validates names like `Europe/Madrid` or `UTC` against the runtime time zone database.', + insertText: 'timeZone', + }, { name: 'uuid', summary: 'UUID string.', From 9580529e221cfd3e2e5d07828affd032719a32ae Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 13 Nov 2026 00:20:53 +0000 Subject: [PATCH 2/5] Bundle timeZone data type icon --- packages/varlock/BUNDLED_ICONS_LICENSES.md | 2 +- .../src/env-graph/lib/type-generation/bundled-icons.gen.ts | 1 + .../type-generation/bundled-icons/mdi--map-clock-outline.svg | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg diff --git a/packages/varlock/BUNDLED_ICONS_LICENSES.md b/packages/varlock/BUNDLED_ICONS_LICENSES.md index a9f196c82..a80b17ff7 100644 --- a/packages/varlock/BUNDLED_ICONS_LICENSES.md +++ b/packages/varlock/BUNDLED_ICONS_LICENSES.md @@ -698,7 +698,7 @@ Apache License - Author: Pictogrammers (https://github.com/Templarian/MaterialDesign) - License: Apache 2.0 (Apache-2.0), https://github.com/Templarian/MaterialDesign/blob/master/LICENSE -- Icons used: `mdi:identifier`, `mdi:timer-outline`, `mdi:web` +- Icons used: `mdi:identifier`, `mdi:map-clock-outline`, `mdi:timer-outline`, `mdi:web` ``` Pictogrammers Free License diff --git a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts index 016e2f5ac..472d29a3b 100644 --- a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts +++ b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts @@ -12,6 +12,7 @@ export const BUNDLED_ICONS: Record = { "material-symbols-light:category": "", "material-symbols:captive-portal": "", "mdi:identifier": "", + "mdi:map-clock-outline": "", "mdi:timer-outline": "", "mdi:web": "", "simple-icons:semver": "", diff --git a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg new file mode 100644 index 000000000..42c0fc98b --- /dev/null +++ b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file From d94e84fb6121c0ade4ae22334ebb143e32f5f73f Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 13 Nov 2026 00:33:54 +0000 Subject: [PATCH 3/5] Reject wrong-cased time zone aliases; mirror casing rules in VS Code diagnostics --- .../varlock/src/env-graph/lib/data-types.ts | 42 +++++++++++-------- .../src/env-graph/test/data-types.test.ts | 12 ++++++ .../vscode-plugin/src/diagnostics-core.ts | 33 ++++++++++++--- .../test/diagnostics-core.test.ts | 9 ++++ 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index 87fd020ed..0ef84a7db 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -977,6 +977,7 @@ const IsoDateDataType = createEnvGraphDataType({ // Engines accept a few things that are not IANA names (UTC offsets like `+05:00`) and match // names case-insensitively, while most consumers (e.g. the `TZ` env var on Linux) are // case-sensitive, so we check both explicitly. +// NOTE: mirrored in packages/vscode-plugin/src/diagnostics-core.ts - keep them in sync. let lowercaseTimeZoneLookup: Map | undefined; function findTimeZoneByCase(val: string): string | undefined { if (!lowercaseTimeZoneLookup) { @@ -986,28 +987,35 @@ function findTimeZoneByCase(val: string): string | undefined { } return lowercaseTimeZoneLookup.get(val.toLowerCase()); } +/** returns an error message, or undefined if `val` is a correctly cased IANA time zone name */ +function checkTimeZoneName(val: string): string | undefined { + if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset'; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; + } catch { + return `"${val}" is not a known IANA time zone`; + } + if (resolved === val) return; + const correctCase = findTimeZoneByCase(val) + ?? (resolved.toLowerCase() === val.toLowerCase() ? resolved : undefined); + if (correctCase) { + return correctCase === val ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`; + } + // A legacy alias (e.g. `US/Eastern`) that the engine canonicalized to another zone. Engines + // do not expose alias casing, but every segment of every IANA name starts with an uppercase + // letter or digit, so a lowercase start is always wrong. + if (val.split('/').some((segment) => /^[a-z]/.test(segment))) { + return `Time zone names are case-sensitive, "${val}" is miscased (it is an alias of "${resolved}")`; + } +} const TimeZoneDataType = createEnvGraphDataType({ name: 'timeZone', icon: 'mdi:map-clock-outline', typeDescription: 'IANA time zone name (e.g. `Europe/Madrid`)', validate(val) { - if (/^[+-]\d/.test(val)) { - return new ValidationError('Value must be an IANA time zone name, not a UTC offset'); - } - let resolved: string; - try { - resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; - } catch { - return new ValidationError(`"${val}" is not a known IANA time zone`); - } - if (resolved !== val) { - const correctCase = findTimeZoneByCase(val) - ?? (resolved.toLowerCase() === val.toLowerCase() ? resolved : undefined); - if (correctCase && correctCase !== val) { - return new ValidationError(`Time zone names are case-sensitive, did you mean "${correctCase}"?`); - } - } - return true; + const error = checkTimeZoneName(val); + return error ? new ValidationError(error) : true; }, }); diff --git a/packages/varlock/src/env-graph/test/data-types.test.ts b/packages/varlock/src/env-graph/test/data-types.test.ts index 93ac9f522..6d48b4c25 100644 --- a/packages/varlock/src/env-graph/test/data-types.test.ts +++ b/packages/varlock/src/env-graph/test/data-types.test.ts @@ -979,6 +979,8 @@ describe('timeZone data type', () => { 'UTC', 'America/Argentina/Buenos_Aires', 'America/Buenos_Aires', // legacy alias + 'US/Eastern', // legacy alias, canonicalized to America/New_York on Node + 'Etc/UTC', 'Etc/GMT+5', ])('accepts %s', async (value) => { const g = await loadAndResolve(outdent` @@ -1013,6 +1015,16 @@ describe('timeZone data type', () => { expect(g.configSchema.TZ.isValid).toBe(false); expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`"${suggestion}"`); }); + + // Node canonicalizes these to a different zone, so no casing suggestion is available + it.each(['us/eastern', 'etc/utc'])('rejects wrong-cased legacy alias %s', async (value) => { + const g = await loadAndResolve(outdent` + # @type=timeZone + TZ=${value} + `); + expect(g.configSchema.TZ.isValid).toBe(false); + expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain('case-sensitive'); + }); }); describe('domain data type', () => { diff --git a/packages/vscode-plugin/src/diagnostics-core.ts b/packages/vscode-plugin/src/diagnostics-core.ts index 45cabc2ed..fed7c688b 100644 --- a/packages/vscode-plugin/src/diagnostics-core.ts +++ b/packages/vscode-plugin/src/diagnostics-core.ts @@ -628,6 +628,32 @@ export function getStaticValueWarning(typeInfo: TypeInfo, value: string) { return undefined; } +// Mirrors the runtime `timeZone` validator in packages/varlock/src/env-graph/lib/data-types.ts. +let lowercaseTimeZoneLookup: Map | undefined; +function checkTimeZoneName(value: string): string | undefined { + if (/^[+-]\d/.test(value)) return 'Value must be an IANA time zone name, not a UTC offset.'; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone; + } catch { + return 'Value must be a known IANA time zone.'; + } + if (resolved === value) return undefined; + if (!lowercaseTimeZoneLookup) { + const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; + lowercaseTimeZoneLookup = new Map((zones ?? []).map((zone) => [zone.toLowerCase(), zone])); + } + const correctCase = lowercaseTimeZoneLookup.get(value.toLowerCase()) + ?? (resolved.toLowerCase() === value.toLowerCase() ? resolved : undefined); + if (correctCase) { + return correctCase === value ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`; + } + if (value.split('/').some((segment) => /^[a-z]/.test(segment))) { + return `Time zone names are case-sensitive, "${value}" is miscased (it is an alias of "${resolved}").`; + } + return undefined; +} + export function validateStaticValue(typeInfo: TypeInfo, value: string) { switch (typeInfo.name) { case 'string': @@ -677,12 +703,7 @@ export function validateStaticValue(typeInfo: TypeInfo, value: string) { ? undefined : 'Value must be a valid ISO date.'; case 'timeZone': - try { - Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions(); - return /^[+-]\d/.test(value) ? 'Value must be an IANA time zone name, not a UTC offset.' : undefined; - } catch { - return 'Value must be a known IANA time zone.'; - } + return checkTimeZoneName(value); case 'uuid': { const version = typeInfo.options.version; const detectedVersion = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(value)?.[1]; diff --git a/packages/vscode-plugin/test/diagnostics-core.test.ts b/packages/vscode-plugin/test/diagnostics-core.test.ts index 708566a69..d96ae8b74 100644 --- a/packages/vscode-plugin/test/diagnostics-core.test.ts +++ b/packages/vscode-plugin/test/diagnostics-core.test.ts @@ -543,6 +543,15 @@ describe('diagnostics-core', () => { ), ).toBe('Value must be a valid IPv4 address.'); + const timeZoneType = { name: 'timeZone', args: [], options: {} }; + expect(validateStaticValue(timeZoneType, 'Europe/Madrid')).toBeUndefined(); + expect(validateStaticValue(timeZoneType, 'US/Eastern')).toBeUndefined(); + expect(validateStaticValue(timeZoneType, 'Europe/Madird')).toBe('Value must be a known IANA time zone.'); + expect(validateStaticValue(timeZoneType, '+05:00')).toBe('Value must be an IANA time zone name, not a UTC offset.'); + expect(validateStaticValue(timeZoneType, 'europe/madrid')).toBe('Time zone names are case-sensitive, did you mean "Europe/Madrid"?'); + expect(validateStaticValue(timeZoneType, 'utc')).toBe('Time zone names are case-sensitive, did you mean "UTC"?'); + expect(validateStaticValue(timeZoneType, 'us/eastern')).toContain('case-sensitive'); + expect( validateStaticValue( { From 24df6e88652668f69d8e84dc054f48e8f8d6b94a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 13 Nov 2026 00:40:24 +0000 Subject: [PATCH 4/5] timeZone: accept only one standard spelling per zone --- .../src/content/docs/reference/data-types.mdx | 2 +- .../varlock/src/env-graph/lib/data-types.ts | 37 +--------- .../varlock/src/env-graph/lib/time-zone.ts | 74 +++++++++++++++++++ .../src/env-graph/test/data-types.test.ts | 18 +++-- .../vscode-plugin/src/diagnostics-core.ts | 33 ++------- packages/vscode-plugin/src/time-zone.ts | 74 +++++++++++++++++++ .../test/diagnostics-core.test.ts | 10 +-- 7 files changed, 170 insertions(+), 78 deletions(-) create mode 100644 packages/varlock/src/env-graph/lib/time-zone.ts create mode 100644 packages/vscode-plugin/src/time-zone.ts diff --git a/packages/varlock-website/src/content/docs/reference/data-types.mdx b/packages/varlock-website/src/content/docs/reference/data-types.mdx index c08b62982..fcdc797ed 100644 --- a/packages/varlock-website/src/content/docs/reference/data-types.mdx +++ b/packages/varlock-website/src/content/docs/reference/data-types.mdx @@ -302,7 +302,7 @@ MY_DATE=2024-03-20T15:30:00Z
### `timeZone` -Checks for a valid [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, using the time zone database built into the JS runtime. Names are case-sensitive, and UTC offsets like `+05:00` are rejected. +Checks for a valid [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, using the time zone database built into the JS runtime. Only one spelling per zone is accepted: names are case-sensitive, legacy aliases like `US/Eastern` or `Asia/Calcutta` are rejected in favor of `America/New_York` or `Asia/Kolkata`, `UTC` is accepted but `Etc/UTC` and `GMT` are not, and UTC offsets like `+05:00` are rejected. The error message suggests the standard name when there is one. ```env-spec # @type=timeZone APP_TIME_ZONE=Europe/Madrid diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index 0ef84a7db..1263460f0 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -2,6 +2,7 @@ import _ from '@env-spec/utils/my-dash'; import { type FallbackIfUnknown } from '@env-spec/utils/type-utils'; import { CoercionError, ValidationError, type ErrorSeverity } from './errors'; import { parseRegexLikeString } from './regex-like-string'; +import { checkTimeZoneName } from './time-zone'; import { parseDuration, convertDurationFromMs, type DurationUnit, } from '../../lib/duration'; @@ -973,42 +974,6 @@ const IsoDateDataType = createEnvGraphDataType({ }); -// Validation leans on the runtime's own tz database via `Intl`, so we ship no zone list. -// Engines accept a few things that are not IANA names (UTC offsets like `+05:00`) and match -// names case-insensitively, while most consumers (e.g. the `TZ` env var on Linux) are -// case-sensitive, so we check both explicitly. -// NOTE: mirrored in packages/vscode-plugin/src/diagnostics-core.ts - keep them in sync. -let lowercaseTimeZoneLookup: Map | undefined; -function findTimeZoneByCase(val: string): string | undefined { - if (!lowercaseTimeZoneLookup) { - lowercaseTimeZoneLookup = new Map(); - const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; - for (const zone of zones ?? []) lowercaseTimeZoneLookup.set(zone.toLowerCase(), zone); - } - return lowercaseTimeZoneLookup.get(val.toLowerCase()); -} -/** returns an error message, or undefined if `val` is a correctly cased IANA time zone name */ -function checkTimeZoneName(val: string): string | undefined { - if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset'; - let resolved: string; - try { - resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; - } catch { - return `"${val}" is not a known IANA time zone`; - } - if (resolved === val) return; - const correctCase = findTimeZoneByCase(val) - ?? (resolved.toLowerCase() === val.toLowerCase() ? resolved : undefined); - if (correctCase) { - return correctCase === val ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`; - } - // A legacy alias (e.g. `US/Eastern`) that the engine canonicalized to another zone. Engines - // do not expose alias casing, but every segment of every IANA name starts with an uppercase - // letter or digit, so a lowercase start is always wrong. - if (val.split('/').some((segment) => /^[a-z]/.test(segment))) { - return `Time zone names are case-sensitive, "${val}" is miscased (it is an alias of "${resolved}")`; - } -} const TimeZoneDataType = createEnvGraphDataType({ name: 'timeZone', icon: 'mdi:map-clock-outline', diff --git a/packages/varlock/src/env-graph/lib/time-zone.ts b/packages/varlock/src/env-graph/lib/time-zone.ts new file mode 100644 index 000000000..76c1231b7 --- /dev/null +++ b/packages/varlock/src/env-graph/lib/time-zone.ts @@ -0,0 +1,74 @@ +/* + Time zone name validation for the `timeZone` data type. + + We lean on the runtime's own tz database via `Intl` so we ship no zone list, but accept only one + spelling per zone: the runtime's canonical names (`Intl.supportedValuesOf('timeZone')`), with + the old spellings CLDR froze for stability swapped for their current IANA names. Engines are + much looser than that (they match case-insensitively, accept legacy aliases like `US/Eastern` + and UTC offsets like `+05:00`) and they disagree with each other on all of it, while most + consumers (e.g. the `TZ` env var on Linux) are case-sensitive and may not know every alias. + + NOTE: copied to packages/vscode-plugin/src/time-zone.ts - keep them in sync. +*/ + +// CLDR keeps these old spellings as canonical ids, so Node reports them instead of the current +// IANA names (Bun reports the name it was given). See https://github.com/tc39/proposal-canonical-tz +const LEGACY_TIME_ZONE_NAMES: Record = { + 'Africa/Asmera': 'Africa/Asmara', + 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', + 'America/Catamarca': 'America/Argentina/Catamarca', + 'America/Coral_Harbour': 'America/Atikokan', + 'America/Cordoba': 'America/Argentina/Cordoba', + 'America/Godthab': 'America/Nuuk', + 'America/Indianapolis': 'America/Indiana/Indianapolis', + 'America/Jujuy': 'America/Argentina/Jujuy', + 'America/Louisville': 'America/Kentucky/Louisville', + 'America/Mendoza': 'America/Argentina/Mendoza', + 'Asia/Calcutta': 'Asia/Kolkata', + 'Asia/Katmandu': 'Asia/Kathmandu', + 'Asia/Rangoon': 'Asia/Yangon', + 'Asia/Saigon': 'Asia/Ho_Chi_Minh', + 'Atlantic/Faeroe': 'Atlantic/Faroe', + 'Europe/Kiev': 'Europe/Kyiv', + 'Pacific/Enderbury': 'Pacific/Kanton', + 'Pacific/Ponape': 'Pacific/Pohnpei', + 'Pacific/Truk': 'Pacific/Chuuk', +}; + +// fixed-offset zones, which Node leaves out of supportedValuesOf +const ETC_GMT_OFFSET_REGEX = /^Etc\/GMT[+-]\d{1,2}$/; +const UTC_ALIAS_REGEX = /^(?:Etc\/)?(?:UTC|UCT|GMT[+-]?0?|Greenwich|Universal|Zulu)$/i; + +let standardNamesByLowercase: Map | undefined; +function getStandardNamesByLowercase() { + if (!standardNamesByLowercase) { + const supported = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; + const names = [...supported ?? [], 'UTC', ...Object.values(LEGACY_TIME_ZONE_NAMES)] + .filter((name) => !(name in LEGACY_TIME_ZONE_NAMES)); + standardNamesByLowercase = new Map(names.map((name) => [name.toLowerCase(), name])); + } + return standardNamesByLowercase; +} + +/** returns an error message, or undefined if `val` is a standard IANA time zone name */ +export function checkTimeZoneName(val: string): string | undefined { + if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset'; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; + } catch { + return `"${val}" is not a known IANA time zone`; + } + const standardNames = getStandardNamesByLowercase(); + if (standardNames.get(val.toLowerCase()) === val || ETC_GMT_OFFSET_REGEX.test(val)) return; + + // wrong case, or else the zone the engine resolved the alias to (Node resolves aliases like + // `US/Eastern`, Bun keeps them as given, so there may be nothing to suggest) + const suggestion = standardNames.get(val.toLowerCase()) + ?? (UTC_ALIAS_REGEX.test(resolved) ? 'UTC' : undefined) + ?? LEGACY_TIME_ZONE_NAMES[resolved] + ?? standardNames.get(resolved.toLowerCase()); + return suggestion + ? `"${val}" is not a standard IANA time zone name, use "${suggestion}"` + : `"${val}" is not a standard IANA time zone name`; +} diff --git a/packages/varlock/src/env-graph/test/data-types.test.ts b/packages/varlock/src/env-graph/test/data-types.test.ts index 6d48b4c25..b99ab8715 100644 --- a/packages/varlock/src/env-graph/test/data-types.test.ts +++ b/packages/varlock/src/env-graph/test/data-types.test.ts @@ -978,9 +978,8 @@ describe('timeZone data type', () => { 'Europe/Madrid', 'UTC', 'America/Argentina/Buenos_Aires', - 'America/Buenos_Aires', // legacy alias - 'US/Eastern', // legacy alias, canonicalized to America/New_York on Node - 'Etc/UTC', + 'Asia/Kolkata', // Node's Intl reports this as the legacy Asia/Calcutta + 'Europe/Amsterdam', // an IANA link since 2022, still a standard name 'Etc/GMT+5', ])('accepts %s', async (value) => { const g = await loadAndResolve(outdent` @@ -1007,23 +1006,26 @@ describe('timeZone data type', () => { it.each([ ['europe/madrid', 'Europe/Madrid'], ['utc', 'UTC'], - ])('rejects wrong casing (%s) and suggests the correct name', async (value, suggestion) => { + ['Etc/UTC', 'UTC'], + ['Asia/Calcutta', 'Asia/Kolkata'], + ['America/Buenos_Aires', 'America/Argentina/Buenos_Aires'], + ])('rejects non-standard spelling %s and suggests %s', async (value, suggestion) => { const g = await loadAndResolve(outdent` # @type=timeZone TZ=${value} `); expect(g.configSchema.TZ.isValid).toBe(false); - expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`"${suggestion}"`); + expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`use "${suggestion}"`); }); - // Node canonicalizes these to a different zone, so no casing suggestion is available - it.each(['us/eastern', 'etc/utc'])('rejects wrong-cased legacy alias %s', async (value) => { + // legacy aliases are rejected in any casing (Node canonicalizes them, Bun keeps them as given) + it.each(['US/Eastern', 'Us/Eastern', 'US/EASTERN', 'us/eastern'])('rejects legacy alias %s', async (value) => { const g = await loadAndResolve(outdent` # @type=timeZone TZ=${value} `); expect(g.configSchema.TZ.isValid).toBe(false); - expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain('case-sensitive'); + expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain('not a standard IANA time zone name'); }); }); diff --git a/packages/vscode-plugin/src/diagnostics-core.ts b/packages/vscode-plugin/src/diagnostics-core.ts index fed7c688b..dc8ce9e4b 100644 --- a/packages/vscode-plugin/src/diagnostics-core.ts +++ b/packages/vscode-plugin/src/diagnostics-core.ts @@ -4,6 +4,7 @@ import { autoCoerce } from '@env-spec/parser'; import type { LineDocument } from './document-lines'; import { DECORATORS_BY_NAME } from './intellisense-catalog'; +import { checkTimeZoneName } from './time-zone'; const DECORATOR_PATTERN = /@([A-Za-z][\w-]*)(?:\([^)]*\)|=[^\s#]+)?/g; const MAX_MATCHES_PATTERN_LENGTH = 200; @@ -628,32 +629,6 @@ export function getStaticValueWarning(typeInfo: TypeInfo, value: string) { return undefined; } -// Mirrors the runtime `timeZone` validator in packages/varlock/src/env-graph/lib/data-types.ts. -let lowercaseTimeZoneLookup: Map | undefined; -function checkTimeZoneName(value: string): string | undefined { - if (/^[+-]\d/.test(value)) return 'Value must be an IANA time zone name, not a UTC offset.'; - let resolved: string; - try { - resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone; - } catch { - return 'Value must be a known IANA time zone.'; - } - if (resolved === value) return undefined; - if (!lowercaseTimeZoneLookup) { - const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; - lowercaseTimeZoneLookup = new Map((zones ?? []).map((zone) => [zone.toLowerCase(), zone])); - } - const correctCase = lowercaseTimeZoneLookup.get(value.toLowerCase()) - ?? (resolved.toLowerCase() === value.toLowerCase() ? resolved : undefined); - if (correctCase) { - return correctCase === value ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`; - } - if (value.split('/').some((segment) => /^[a-z]/.test(segment))) { - return `Time zone names are case-sensitive, "${value}" is miscased (it is an alias of "${resolved}").`; - } - return undefined; -} - export function validateStaticValue(typeInfo: TypeInfo, value: string) { switch (typeInfo.name) { case 'string': @@ -702,8 +677,10 @@ export function validateStaticValue(typeInfo: TypeInfo, value: string) { return /^\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+-Z]*)?$/.test(value) && !Number.isNaN(Date.parse(value)) ? undefined : 'Value must be a valid ISO date.'; - case 'timeZone': - return checkTimeZoneName(value); + case 'timeZone': { + const error = checkTimeZoneName(value); + return error ? `${error}.` : undefined; + } case 'uuid': { const version = typeInfo.options.version; const detectedVersion = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(value)?.[1]; diff --git a/packages/vscode-plugin/src/time-zone.ts b/packages/vscode-plugin/src/time-zone.ts new file mode 100644 index 000000000..7c8b034ad --- /dev/null +++ b/packages/vscode-plugin/src/time-zone.ts @@ -0,0 +1,74 @@ +/* + Time zone name validation for the `timeZone` data type. + + We lean on the runtime's own tz database via `Intl` so we ship no zone list, but accept only one + spelling per zone: the runtime's canonical names (`Intl.supportedValuesOf('timeZone')`), with + the old spellings CLDR froze for stability swapped for their current IANA names. Engines are + much looser than that (they match case-insensitively, accept legacy aliases like `US/Eastern` + and UTC offsets like `+05:00`) and they disagree with each other on all of it, while most + consumers (e.g. the `TZ` env var on Linux) are case-sensitive and may not know every alias. + + NOTE: copy of packages/varlock/src/env-graph/lib/time-zone.ts - keep them in sync. +*/ + +// CLDR keeps these old spellings as canonical ids, so Node reports them instead of the current +// IANA names (Bun reports the name it was given). See https://github.com/tc39/proposal-canonical-tz +const LEGACY_TIME_ZONE_NAMES: Record = { + 'Africa/Asmera': 'Africa/Asmara', + 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', + 'America/Catamarca': 'America/Argentina/Catamarca', + 'America/Coral_Harbour': 'America/Atikokan', + 'America/Cordoba': 'America/Argentina/Cordoba', + 'America/Godthab': 'America/Nuuk', + 'America/Indianapolis': 'America/Indiana/Indianapolis', + 'America/Jujuy': 'America/Argentina/Jujuy', + 'America/Louisville': 'America/Kentucky/Louisville', + 'America/Mendoza': 'America/Argentina/Mendoza', + 'Asia/Calcutta': 'Asia/Kolkata', + 'Asia/Katmandu': 'Asia/Kathmandu', + 'Asia/Rangoon': 'Asia/Yangon', + 'Asia/Saigon': 'Asia/Ho_Chi_Minh', + 'Atlantic/Faeroe': 'Atlantic/Faroe', + 'Europe/Kiev': 'Europe/Kyiv', + 'Pacific/Enderbury': 'Pacific/Kanton', + 'Pacific/Ponape': 'Pacific/Pohnpei', + 'Pacific/Truk': 'Pacific/Chuuk', +}; + +// fixed-offset zones, which Node leaves out of supportedValuesOf +const ETC_GMT_OFFSET_REGEX = /^Etc\/GMT[+-]\d{1,2}$/; +const UTC_ALIAS_REGEX = /^(?:Etc\/)?(?:UTC|UCT|GMT[+-]?0?|Greenwich|Universal|Zulu)$/i; + +let standardNamesByLowercase: Map | undefined; +function getStandardNamesByLowercase() { + if (!standardNamesByLowercase) { + const supported = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; + const names = [...supported ?? [], 'UTC', ...Object.values(LEGACY_TIME_ZONE_NAMES)] + .filter((name) => !(name in LEGACY_TIME_ZONE_NAMES)); + standardNamesByLowercase = new Map(names.map((name) => [name.toLowerCase(), name])); + } + return standardNamesByLowercase; +} + +/** returns an error message, or undefined if `val` is a standard IANA time zone name */ +export function checkTimeZoneName(val: string): string | undefined { + if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset'; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; + } catch { + return `"${val}" is not a known IANA time zone`; + } + const standardNames = getStandardNamesByLowercase(); + if (standardNames.get(val.toLowerCase()) === val || ETC_GMT_OFFSET_REGEX.test(val)) return; + + // wrong case, or else the zone the engine resolved the alias to (Node resolves aliases like + // `US/Eastern`, Bun keeps them as given, so there may be nothing to suggest) + const suggestion = standardNames.get(val.toLowerCase()) + ?? (UTC_ALIAS_REGEX.test(resolved) ? 'UTC' : undefined) + ?? LEGACY_TIME_ZONE_NAMES[resolved] + ?? standardNames.get(resolved.toLowerCase()); + return suggestion + ? `"${val}" is not a standard IANA time zone name, use "${suggestion}"` + : `"${val}" is not a standard IANA time zone name`; +} diff --git a/packages/vscode-plugin/test/diagnostics-core.test.ts b/packages/vscode-plugin/test/diagnostics-core.test.ts index d96ae8b74..6b6bd5872 100644 --- a/packages/vscode-plugin/test/diagnostics-core.test.ts +++ b/packages/vscode-plugin/test/diagnostics-core.test.ts @@ -545,12 +545,12 @@ describe('diagnostics-core', () => { const timeZoneType = { name: 'timeZone', args: [], options: {} }; expect(validateStaticValue(timeZoneType, 'Europe/Madrid')).toBeUndefined(); - expect(validateStaticValue(timeZoneType, 'US/Eastern')).toBeUndefined(); - expect(validateStaticValue(timeZoneType, 'Europe/Madird')).toBe('Value must be a known IANA time zone.'); + expect(validateStaticValue(timeZoneType, 'Asia/Kolkata')).toBeUndefined(); + expect(validateStaticValue(timeZoneType, 'Europe/Madird')).toBe('"Europe/Madird" is not a known IANA time zone.'); expect(validateStaticValue(timeZoneType, '+05:00')).toBe('Value must be an IANA time zone name, not a UTC offset.'); - expect(validateStaticValue(timeZoneType, 'europe/madrid')).toBe('Time zone names are case-sensitive, did you mean "Europe/Madrid"?'); - expect(validateStaticValue(timeZoneType, 'utc')).toBe('Time zone names are case-sensitive, did you mean "UTC"?'); - expect(validateStaticValue(timeZoneType, 'us/eastern')).toContain('case-sensitive'); + expect(validateStaticValue(timeZoneType, 'europe/madrid')).toBe('"europe/madrid" is not a standard IANA time zone name, use "Europe/Madrid".'); + expect(validateStaticValue(timeZoneType, 'Asia/Calcutta')).toContain('use "Asia/Kolkata"'); + expect(validateStaticValue(timeZoneType, 'US/Eastern')).toContain('not a standard IANA time zone name'); expect( validateStaticValue( From fa065adc0f357dfa6d04e52fa25944724b84951e Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Fri, 13 Nov 2026 00:50:29 +0000 Subject: [PATCH 5/5] timeZone: go back to accepting any runtime-known name (case and offsets still checked) --- .../src/content/docs/reference/data-types.mdx | 2 +- .../varlock/src/env-graph/lib/data-types.ts | 37 +++++++++- .../varlock/src/env-graph/lib/time-zone.ts | 74 ------------------- .../src/env-graph/test/data-types.test.ts | 18 ++--- .../vscode-plugin/src/diagnostics-core.ts | 33 +++++++-- packages/vscode-plugin/src/time-zone.ts | 74 ------------------- .../test/diagnostics-core.test.ts | 10 +-- 7 files changed, 78 insertions(+), 170 deletions(-) delete mode 100644 packages/varlock/src/env-graph/lib/time-zone.ts delete mode 100644 packages/vscode-plugin/src/time-zone.ts diff --git a/packages/varlock-website/src/content/docs/reference/data-types.mdx b/packages/varlock-website/src/content/docs/reference/data-types.mdx index fcdc797ed..c08b62982 100644 --- a/packages/varlock-website/src/content/docs/reference/data-types.mdx +++ b/packages/varlock-website/src/content/docs/reference/data-types.mdx @@ -302,7 +302,7 @@ MY_DATE=2024-03-20T15:30:00Z
### `timeZone` -Checks for a valid [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, using the time zone database built into the JS runtime. Only one spelling per zone is accepted: names are case-sensitive, legacy aliases like `US/Eastern` or `Asia/Calcutta` are rejected in favor of `America/New_York` or `Asia/Kolkata`, `UTC` is accepted but `Etc/UTC` and `GMT` are not, and UTC offsets like `+05:00` are rejected. The error message suggests the standard name when there is one. +Checks for a valid [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, using the time zone database built into the JS runtime. Names are case-sensitive, and UTC offsets like `+05:00` are rejected. ```env-spec # @type=timeZone APP_TIME_ZONE=Europe/Madrid diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index 1263460f0..0ef84a7db 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -2,7 +2,6 @@ import _ from '@env-spec/utils/my-dash'; import { type FallbackIfUnknown } from '@env-spec/utils/type-utils'; import { CoercionError, ValidationError, type ErrorSeverity } from './errors'; import { parseRegexLikeString } from './regex-like-string'; -import { checkTimeZoneName } from './time-zone'; import { parseDuration, convertDurationFromMs, type DurationUnit, } from '../../lib/duration'; @@ -974,6 +973,42 @@ const IsoDateDataType = createEnvGraphDataType({ }); +// Validation leans on the runtime's own tz database via `Intl`, so we ship no zone list. +// Engines accept a few things that are not IANA names (UTC offsets like `+05:00`) and match +// names case-insensitively, while most consumers (e.g. the `TZ` env var on Linux) are +// case-sensitive, so we check both explicitly. +// NOTE: mirrored in packages/vscode-plugin/src/diagnostics-core.ts - keep them in sync. +let lowercaseTimeZoneLookup: Map | undefined; +function findTimeZoneByCase(val: string): string | undefined { + if (!lowercaseTimeZoneLookup) { + lowercaseTimeZoneLookup = new Map(); + const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; + for (const zone of zones ?? []) lowercaseTimeZoneLookup.set(zone.toLowerCase(), zone); + } + return lowercaseTimeZoneLookup.get(val.toLowerCase()); +} +/** returns an error message, or undefined if `val` is a correctly cased IANA time zone name */ +function checkTimeZoneName(val: string): string | undefined { + if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset'; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; + } catch { + return `"${val}" is not a known IANA time zone`; + } + if (resolved === val) return; + const correctCase = findTimeZoneByCase(val) + ?? (resolved.toLowerCase() === val.toLowerCase() ? resolved : undefined); + if (correctCase) { + return correctCase === val ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`; + } + // A legacy alias (e.g. `US/Eastern`) that the engine canonicalized to another zone. Engines + // do not expose alias casing, but every segment of every IANA name starts with an uppercase + // letter or digit, so a lowercase start is always wrong. + if (val.split('/').some((segment) => /^[a-z]/.test(segment))) { + return `Time zone names are case-sensitive, "${val}" is miscased (it is an alias of "${resolved}")`; + } +} const TimeZoneDataType = createEnvGraphDataType({ name: 'timeZone', icon: 'mdi:map-clock-outline', diff --git a/packages/varlock/src/env-graph/lib/time-zone.ts b/packages/varlock/src/env-graph/lib/time-zone.ts deleted file mode 100644 index 76c1231b7..000000000 --- a/packages/varlock/src/env-graph/lib/time-zone.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - Time zone name validation for the `timeZone` data type. - - We lean on the runtime's own tz database via `Intl` so we ship no zone list, but accept only one - spelling per zone: the runtime's canonical names (`Intl.supportedValuesOf('timeZone')`), with - the old spellings CLDR froze for stability swapped for their current IANA names. Engines are - much looser than that (they match case-insensitively, accept legacy aliases like `US/Eastern` - and UTC offsets like `+05:00`) and they disagree with each other on all of it, while most - consumers (e.g. the `TZ` env var on Linux) are case-sensitive and may not know every alias. - - NOTE: copied to packages/vscode-plugin/src/time-zone.ts - keep them in sync. -*/ - -// CLDR keeps these old spellings as canonical ids, so Node reports them instead of the current -// IANA names (Bun reports the name it was given). See https://github.com/tc39/proposal-canonical-tz -const LEGACY_TIME_ZONE_NAMES: Record = { - 'Africa/Asmera': 'Africa/Asmara', - 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', - 'America/Catamarca': 'America/Argentina/Catamarca', - 'America/Coral_Harbour': 'America/Atikokan', - 'America/Cordoba': 'America/Argentina/Cordoba', - 'America/Godthab': 'America/Nuuk', - 'America/Indianapolis': 'America/Indiana/Indianapolis', - 'America/Jujuy': 'America/Argentina/Jujuy', - 'America/Louisville': 'America/Kentucky/Louisville', - 'America/Mendoza': 'America/Argentina/Mendoza', - 'Asia/Calcutta': 'Asia/Kolkata', - 'Asia/Katmandu': 'Asia/Kathmandu', - 'Asia/Rangoon': 'Asia/Yangon', - 'Asia/Saigon': 'Asia/Ho_Chi_Minh', - 'Atlantic/Faeroe': 'Atlantic/Faroe', - 'Europe/Kiev': 'Europe/Kyiv', - 'Pacific/Enderbury': 'Pacific/Kanton', - 'Pacific/Ponape': 'Pacific/Pohnpei', - 'Pacific/Truk': 'Pacific/Chuuk', -}; - -// fixed-offset zones, which Node leaves out of supportedValuesOf -const ETC_GMT_OFFSET_REGEX = /^Etc\/GMT[+-]\d{1,2}$/; -const UTC_ALIAS_REGEX = /^(?:Etc\/)?(?:UTC|UCT|GMT[+-]?0?|Greenwich|Universal|Zulu)$/i; - -let standardNamesByLowercase: Map | undefined; -function getStandardNamesByLowercase() { - if (!standardNamesByLowercase) { - const supported = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; - const names = [...supported ?? [], 'UTC', ...Object.values(LEGACY_TIME_ZONE_NAMES)] - .filter((name) => !(name in LEGACY_TIME_ZONE_NAMES)); - standardNamesByLowercase = new Map(names.map((name) => [name.toLowerCase(), name])); - } - return standardNamesByLowercase; -} - -/** returns an error message, or undefined if `val` is a standard IANA time zone name */ -export function checkTimeZoneName(val: string): string | undefined { - if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset'; - let resolved: string; - try { - resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; - } catch { - return `"${val}" is not a known IANA time zone`; - } - const standardNames = getStandardNamesByLowercase(); - if (standardNames.get(val.toLowerCase()) === val || ETC_GMT_OFFSET_REGEX.test(val)) return; - - // wrong case, or else the zone the engine resolved the alias to (Node resolves aliases like - // `US/Eastern`, Bun keeps them as given, so there may be nothing to suggest) - const suggestion = standardNames.get(val.toLowerCase()) - ?? (UTC_ALIAS_REGEX.test(resolved) ? 'UTC' : undefined) - ?? LEGACY_TIME_ZONE_NAMES[resolved] - ?? standardNames.get(resolved.toLowerCase()); - return suggestion - ? `"${val}" is not a standard IANA time zone name, use "${suggestion}"` - : `"${val}" is not a standard IANA time zone name`; -} diff --git a/packages/varlock/src/env-graph/test/data-types.test.ts b/packages/varlock/src/env-graph/test/data-types.test.ts index b99ab8715..6d48b4c25 100644 --- a/packages/varlock/src/env-graph/test/data-types.test.ts +++ b/packages/varlock/src/env-graph/test/data-types.test.ts @@ -978,8 +978,9 @@ describe('timeZone data type', () => { 'Europe/Madrid', 'UTC', 'America/Argentina/Buenos_Aires', - 'Asia/Kolkata', // Node's Intl reports this as the legacy Asia/Calcutta - 'Europe/Amsterdam', // an IANA link since 2022, still a standard name + 'America/Buenos_Aires', // legacy alias + 'US/Eastern', // legacy alias, canonicalized to America/New_York on Node + 'Etc/UTC', 'Etc/GMT+5', ])('accepts %s', async (value) => { const g = await loadAndResolve(outdent` @@ -1006,26 +1007,23 @@ describe('timeZone data type', () => { it.each([ ['europe/madrid', 'Europe/Madrid'], ['utc', 'UTC'], - ['Etc/UTC', 'UTC'], - ['Asia/Calcutta', 'Asia/Kolkata'], - ['America/Buenos_Aires', 'America/Argentina/Buenos_Aires'], - ])('rejects non-standard spelling %s and suggests %s', async (value, suggestion) => { + ])('rejects wrong casing (%s) and suggests the correct name', async (value, suggestion) => { const g = await loadAndResolve(outdent` # @type=timeZone TZ=${value} `); expect(g.configSchema.TZ.isValid).toBe(false); - expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`use "${suggestion}"`); + expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`"${suggestion}"`); }); - // legacy aliases are rejected in any casing (Node canonicalizes them, Bun keeps them as given) - it.each(['US/Eastern', 'Us/Eastern', 'US/EASTERN', 'us/eastern'])('rejects legacy alias %s', async (value) => { + // Node canonicalizes these to a different zone, so no casing suggestion is available + it.each(['us/eastern', 'etc/utc'])('rejects wrong-cased legacy alias %s', async (value) => { const g = await loadAndResolve(outdent` # @type=timeZone TZ=${value} `); expect(g.configSchema.TZ.isValid).toBe(false); - expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain('not a standard IANA time zone name'); + expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain('case-sensitive'); }); }); diff --git a/packages/vscode-plugin/src/diagnostics-core.ts b/packages/vscode-plugin/src/diagnostics-core.ts index dc8ce9e4b..fed7c688b 100644 --- a/packages/vscode-plugin/src/diagnostics-core.ts +++ b/packages/vscode-plugin/src/diagnostics-core.ts @@ -4,7 +4,6 @@ import { autoCoerce } from '@env-spec/parser'; import type { LineDocument } from './document-lines'; import { DECORATORS_BY_NAME } from './intellisense-catalog'; -import { checkTimeZoneName } from './time-zone'; const DECORATOR_PATTERN = /@([A-Za-z][\w-]*)(?:\([^)]*\)|=[^\s#]+)?/g; const MAX_MATCHES_PATTERN_LENGTH = 200; @@ -629,6 +628,32 @@ export function getStaticValueWarning(typeInfo: TypeInfo, value: string) { return undefined; } +// Mirrors the runtime `timeZone` validator in packages/varlock/src/env-graph/lib/data-types.ts. +let lowercaseTimeZoneLookup: Map | undefined; +function checkTimeZoneName(value: string): string | undefined { + if (/^[+-]\d/.test(value)) return 'Value must be an IANA time zone name, not a UTC offset.'; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone; + } catch { + return 'Value must be a known IANA time zone.'; + } + if (resolved === value) return undefined; + if (!lowercaseTimeZoneLookup) { + const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; + lowercaseTimeZoneLookup = new Map((zones ?? []).map((zone) => [zone.toLowerCase(), zone])); + } + const correctCase = lowercaseTimeZoneLookup.get(value.toLowerCase()) + ?? (resolved.toLowerCase() === value.toLowerCase() ? resolved : undefined); + if (correctCase) { + return correctCase === value ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`; + } + if (value.split('/').some((segment) => /^[a-z]/.test(segment))) { + return `Time zone names are case-sensitive, "${value}" is miscased (it is an alias of "${resolved}").`; + } + return undefined; +} + export function validateStaticValue(typeInfo: TypeInfo, value: string) { switch (typeInfo.name) { case 'string': @@ -677,10 +702,8 @@ export function validateStaticValue(typeInfo: TypeInfo, value: string) { return /^\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+-Z]*)?$/.test(value) && !Number.isNaN(Date.parse(value)) ? undefined : 'Value must be a valid ISO date.'; - case 'timeZone': { - const error = checkTimeZoneName(value); - return error ? `${error}.` : undefined; - } + case 'timeZone': + return checkTimeZoneName(value); case 'uuid': { const version = typeInfo.options.version; const detectedVersion = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(value)?.[1]; diff --git a/packages/vscode-plugin/src/time-zone.ts b/packages/vscode-plugin/src/time-zone.ts deleted file mode 100644 index 7c8b034ad..000000000 --- a/packages/vscode-plugin/src/time-zone.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - Time zone name validation for the `timeZone` data type. - - We lean on the runtime's own tz database via `Intl` so we ship no zone list, but accept only one - spelling per zone: the runtime's canonical names (`Intl.supportedValuesOf('timeZone')`), with - the old spellings CLDR froze for stability swapped for their current IANA names. Engines are - much looser than that (they match case-insensitively, accept legacy aliases like `US/Eastern` - and UTC offsets like `+05:00`) and they disagree with each other on all of it, while most - consumers (e.g. the `TZ` env var on Linux) are case-sensitive and may not know every alias. - - NOTE: copy of packages/varlock/src/env-graph/lib/time-zone.ts - keep them in sync. -*/ - -// CLDR keeps these old spellings as canonical ids, so Node reports them instead of the current -// IANA names (Bun reports the name it was given). See https://github.com/tc39/proposal-canonical-tz -const LEGACY_TIME_ZONE_NAMES: Record = { - 'Africa/Asmera': 'Africa/Asmara', - 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', - 'America/Catamarca': 'America/Argentina/Catamarca', - 'America/Coral_Harbour': 'America/Atikokan', - 'America/Cordoba': 'America/Argentina/Cordoba', - 'America/Godthab': 'America/Nuuk', - 'America/Indianapolis': 'America/Indiana/Indianapolis', - 'America/Jujuy': 'America/Argentina/Jujuy', - 'America/Louisville': 'America/Kentucky/Louisville', - 'America/Mendoza': 'America/Argentina/Mendoza', - 'Asia/Calcutta': 'Asia/Kolkata', - 'Asia/Katmandu': 'Asia/Kathmandu', - 'Asia/Rangoon': 'Asia/Yangon', - 'Asia/Saigon': 'Asia/Ho_Chi_Minh', - 'Atlantic/Faeroe': 'Atlantic/Faroe', - 'Europe/Kiev': 'Europe/Kyiv', - 'Pacific/Enderbury': 'Pacific/Kanton', - 'Pacific/Ponape': 'Pacific/Pohnpei', - 'Pacific/Truk': 'Pacific/Chuuk', -}; - -// fixed-offset zones, which Node leaves out of supportedValuesOf -const ETC_GMT_OFFSET_REGEX = /^Etc\/GMT[+-]\d{1,2}$/; -const UTC_ALIAS_REGEX = /^(?:Etc\/)?(?:UTC|UCT|GMT[+-]?0?|Greenwich|Universal|Zulu)$/i; - -let standardNamesByLowercase: Map | undefined; -function getStandardNamesByLowercase() { - if (!standardNamesByLowercase) { - const supported = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; - const names = [...supported ?? [], 'UTC', ...Object.values(LEGACY_TIME_ZONE_NAMES)] - .filter((name) => !(name in LEGACY_TIME_ZONE_NAMES)); - standardNamesByLowercase = new Map(names.map((name) => [name.toLowerCase(), name])); - } - return standardNamesByLowercase; -} - -/** returns an error message, or undefined if `val` is a standard IANA time zone name */ -export function checkTimeZoneName(val: string): string | undefined { - if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset'; - let resolved: string; - try { - resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; - } catch { - return `"${val}" is not a known IANA time zone`; - } - const standardNames = getStandardNamesByLowercase(); - if (standardNames.get(val.toLowerCase()) === val || ETC_GMT_OFFSET_REGEX.test(val)) return; - - // wrong case, or else the zone the engine resolved the alias to (Node resolves aliases like - // `US/Eastern`, Bun keeps them as given, so there may be nothing to suggest) - const suggestion = standardNames.get(val.toLowerCase()) - ?? (UTC_ALIAS_REGEX.test(resolved) ? 'UTC' : undefined) - ?? LEGACY_TIME_ZONE_NAMES[resolved] - ?? standardNames.get(resolved.toLowerCase()); - return suggestion - ? `"${val}" is not a standard IANA time zone name, use "${suggestion}"` - : `"${val}" is not a standard IANA time zone name`; -} diff --git a/packages/vscode-plugin/test/diagnostics-core.test.ts b/packages/vscode-plugin/test/diagnostics-core.test.ts index 6b6bd5872..d96ae8b74 100644 --- a/packages/vscode-plugin/test/diagnostics-core.test.ts +++ b/packages/vscode-plugin/test/diagnostics-core.test.ts @@ -545,12 +545,12 @@ describe('diagnostics-core', () => { const timeZoneType = { name: 'timeZone', args: [], options: {} }; expect(validateStaticValue(timeZoneType, 'Europe/Madrid')).toBeUndefined(); - expect(validateStaticValue(timeZoneType, 'Asia/Kolkata')).toBeUndefined(); - expect(validateStaticValue(timeZoneType, 'Europe/Madird')).toBe('"Europe/Madird" is not a known IANA time zone.'); + expect(validateStaticValue(timeZoneType, 'US/Eastern')).toBeUndefined(); + expect(validateStaticValue(timeZoneType, 'Europe/Madird')).toBe('Value must be a known IANA time zone.'); expect(validateStaticValue(timeZoneType, '+05:00')).toBe('Value must be an IANA time zone name, not a UTC offset.'); - expect(validateStaticValue(timeZoneType, 'europe/madrid')).toBe('"europe/madrid" is not a standard IANA time zone name, use "Europe/Madrid".'); - expect(validateStaticValue(timeZoneType, 'Asia/Calcutta')).toContain('use "Asia/Kolkata"'); - expect(validateStaticValue(timeZoneType, 'US/Eastern')).toContain('not a standard IANA time zone name'); + expect(validateStaticValue(timeZoneType, 'europe/madrid')).toBe('Time zone names are case-sensitive, did you mean "Europe/Madrid"?'); + expect(validateStaticValue(timeZoneType, 'utc')).toBe('Time zone names are case-sensitive, did you mean "UTC"?'); + expect(validateStaticValue(timeZoneType, 'us/eastern')).toContain('case-sensitive'); expect( validateStaticValue(