diff --git a/doc/main_branch_protection.md b/doc/main_branch_protection.md index 98949e2..bb72ee1 100644 --- a/doc/main_branch_protection.md +++ b/doc/main_branch_protection.md @@ -125,7 +125,8 @@ git checkout -b feature/my-cool-feature git stash pop # 4. Commit -git commit -am "My feature changes" +git add . +git commit -m "My feature changes" ``` ### "I accidentally committed before I added the hook!" @@ -139,6 +140,9 @@ git branch feature/my-feature git fetch origin # 3. Reset your local 'main' back to where it should be (the remote version) # (Fetching first ensures you don't accidentally reset to a stale origin/main reference) +# SAFETY CHECK: Verify that your working tree is clean (e.g. `git status`). +# Stash or commit any uncommitted changes before proceeding! +# ⚠️ This reset will DISCARD ALL UNCOMMITTED LOCAL CHANGES. git reset --hard origin/main # 4. Switch to your new branch to continue working diff --git a/packages/tempo/plan/release-process.md b/doc/release-process.md similarity index 100% rename from packages/tempo/plan/release-process.md rename to doc/release-process.md diff --git a/package-lock.json b/package-lock.json index 6e26a60..a1583d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "2.9.1", + "version": "2.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "2.9.1", + "version": "2.9.2", "workspaces": [ "packages/*" ], @@ -18,7 +18,8 @@ "@types/jquery": "^4.0.0", "@types/node": "^25.5.2", "@vitest/ui": "^2.1.8", - "cross-env": "^7.0.3", + "cross-env": "^10.1.0", + "markdown-it-mathjax3": "^4.3.2", "rollup": "^4.60.1", "tslib": "^2.8.1", "tsx": "^4.21.0", @@ -385,6 +386,13 @@ } } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1316,6 +1324,177 @@ "win32" ] }, + "node_modules/@se-oss/deasync": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync/-/deasync-1.0.1.tgz", + "integrity": "sha512-Ha7P/xCNxOuH72BNdLRWs4TT8rsMMrERnHtfKWBeTWu+UFW9OBTrRgfZJOlbAAQFR0l4Q30cpAn8CuR7PXWcPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.37.0" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@se-oss/deasync-darwin-arm64": "1.0.1", + "@se-oss/deasync-darwin-x64": "1.0.1", + "@se-oss/deasync-linux-arm64-gnu": "1.0.1", + "@se-oss/deasync-linux-arm64-musl": "1.0.1", + "@se-oss/deasync-linux-x64-gnu": "1.0.1", + "@se-oss/deasync-linux-x64-musl": "1.0.1", + "@se-oss/deasync-win32-arm64-msvc": "1.0.1", + "@se-oss/deasync-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@se-oss/deasync-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync-darwin-arm64/-/deasync-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-0YWmIDEGQfW3GGopmZHhfA6mamsG0HFKZhmBzHVyFiMKkJts8kpQwGbGrWlK8eOAoPCihOsG6tCotYR3p7HZaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@se-oss/deasync-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync-darwin-x64/-/deasync-darwin-x64-1.0.1.tgz", + "integrity": "sha512-r3FRTLIXqGqOb1DjTLW3YhO/Dd1vA2qRLP0Ym3Wmk3yMv6c/nm15zg6UVoXbgBu8cjbvcsI/OfbHPdErmjMWsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@se-oss/deasync-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync-linux-arm64-gnu/-/deasync-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-657uRew7fZAx663Li03ilLV2lN09Dqb/NxawlDu8kKmboK1BLitHJRS+taiT5oFZqyIDrU45tlQKfCrW0p0sYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@se-oss/deasync-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync-linux-arm64-musl/-/deasync-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-IE3fIQPIJtko4lx9sRam+Zz0P4xbpAPJgDCHaz6k9cP1yUvVI179B4IZRnFx0GyjyQpm0KhHoIGHJc4KUmA81Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@se-oss/deasync-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync-linux-x64-gnu/-/deasync-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XQl7etZESGIjIraCyxfAey8ZTIJUB4dUFU3rPR/xLVn9bKpZGlJLIms0z3hoHX9mipO+Cqo53vK4IVm6A7U/ww==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@se-oss/deasync-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync-linux-x64-musl/-/deasync-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-vWgFAZlqImqMV6jhCWV7C9wcCS1eb1ajhlKduBRPfyUxxkoObe+EqTG2BKJAuafxp3/KS1aUsIMJma9mhwFvow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@se-oss/deasync-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync-win32-arm64-msvc/-/deasync-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-yk7lEE7Zd8GX7o6CuUbg3HnnmUhBx4tgfn5ff3eoq05CgBO6Z3ZtL4l+utAe1cxcFaXPhyvcgnHYyA4OF544tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@se-oss/deasync-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@se-oss/deasync-win32-x64-msvc/-/deasync-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-ixizmuLGRPGyAesWUNWVzVOsvuunNb/qMqU8SmjfLR/vVgzdQEkSHFf+fkX9GXPN6FDv+DAz5uskTzhjUyCXFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@shikijs/core": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", @@ -2212,22 +2391,21 @@ } }, "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.1" + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" }, "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" }, "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" + "node": ">=20" } }, "node_modules/cross-spawn": { @@ -2499,6 +2677,19 @@ "node": ">=18" } }, + "node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", @@ -2860,6 +3051,13 @@ "speech-rule-engine": "^4.0.6" } }, + "node_modules/mathxyjax3": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/mathxyjax3/-/mathxyjax3-0.8.3.tgz", + "integrity": "sha512-eXjFaiyQsTdVOeTFoFaFJ/r1FITpB1f9c5MW4FETfcoVV/+xa5SD9pS05AwugzL/gNuDtWXrTOSmoD2e0Du+UA==", + "dev": true, + "license": "MIT" + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -3067,6 +3265,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -4163,6 +4382,19 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typedoc": { "version": "0.28.19", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.19.tgz", @@ -4658,19 +4890,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/web-resource-inliner/node_modules/escape-goat": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", - "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/web-resource-inliner/node_modules/htmlparser2": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", @@ -4687,27 +4906,6 @@ "url": "https://github.com/fb55/htmlparser2?sponsor=1" } }, - "node_modules/web-resource-inliner/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -4795,7 +4993,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "2.9.1", + "version": "2.9.2", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -4814,16 +5012,15 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "2.9.1", + "version": "2.9.2", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.9.1", + "@magmacomputing/library": "2.9.2", "@rollup/plugin-alias": "^6.0.0", - "cross-env": "^7.0.3", "magic-string": "^0.30.21", "markdown-it-mathjax3": "^4.3.2", "typedoc": "^0.28.19", @@ -4831,6 +5028,17 @@ "typedoc-vitepress-theme": "^1.1.2", "vitepress": "^1.6.4" } + }, + "packages/tempo/node_modules/markdown-it-mathjax3": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/markdown-it-mathjax3/-/markdown-it-mathjax3-5.2.0.tgz", + "integrity": "sha512-R+XAy5/7vSGuhG9Z0/cJm6zKxOzStcScfSKVwoarh4nBra+v1KClvbALr/xFTEe9iQhwfQM4SJnO68LXL+btMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@se-oss/deasync": "^1.0.1", + "mathxyjax3": "^0.8.3" + } } } } diff --git a/package.json b/package.json index 0e2644a..36815a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.9.1", + "version": "2.9.2", "private": true, "description": "Magma Computing Monorepo", "repository": { @@ -31,8 +31,9 @@ "@types/jquery": "^4.0.0", "@types/node": "^25.5.2", "@vitest/ui": "^2.1.8", - "cross-env": "^7.0.3", + "cross-env": "^10.1.0", "rollup": "^4.60.1", + "markdown-it-mathjax3": "^4.3.2", "tslib": "^2.8.1", "tsx": "^4.21.0", "typescript": "^6.0.2", diff --git a/packages/library/package.json b/packages/library/package.json index 789c165..3cc5edf 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.9.1", + "version": "2.9.2", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/assertion.library.ts index ac23489..aa3fbe7 100644 --- a/packages/library/src/common/assertion.library.ts +++ b/packages/library/src/common/assertion.library.ts @@ -65,11 +65,11 @@ export const isTemporal = (obj: T): obj is Extract => protoType (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay )); -export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (!!obj && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId)); -export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (!!obj && typeof (obj as any).toInstant === 'function' && isDefined((obj as any).timeZoneId)); -export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond)); +export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (!!obj && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone)); +export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (!!obj && typeof (obj as any).toInstant === 'function' && (isDefined((obj as any).timeZoneId) || isDefined((obj as any).timeZone))); +export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond)); export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime') || (!!obj && typeof (obj as any).toPlainDateTime === 'function' && isUndefined((obj as any).daysInMonth)); -export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond))); +export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond))); export const isDuration = (obj: T): obj is Extract => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Duration'); export const isDurationLike = (obj: T): obj is Extract => isString(obj) || isDuration(obj) || (isObject(obj) && ( 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || diff --git a/packages/library/src/common/temporal.library.ts b/packages/library/src/common/temporal.library.ts index 5a7acdd..63e0272 100644 --- a/packages/library/src/common/temporal.library.ts +++ b/packages/library/src/common/temporal.library.ts @@ -4,7 +4,7 @@ */ import '#library/temporal.polyfill.js'; // ensure Temporal is available -import { isNumber, isString } from '#library/assertion.library.js'; +import { isNumber, isObject, isString, isDefined, isZonedDateTime } from '#library/assertion.library.js'; /** return the current Temporal.Now.instant */ export function instant() { @@ -36,7 +36,7 @@ export function getOffsets(timeZone: string, year = 2024) { //** use a fixed ref /** return whether the given (or current) date is in Daylight Savings */ export function isDST(date?: Temporal.ZonedDateTime | string, timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone) { - const zdt = (typeof date === 'string') + const zdt = isString(date) ? Temporal.ZonedDateTime.from(date) : (date ?? instant().toZonedDateTimeISO(timeZone)); const { jan, jul } = getOffsets(zdt.timeZoneId, zdt.year); @@ -81,9 +81,14 @@ export function normaliseFractionalDurations(payload: Record) { /** * ## toZonedDateTime * Create a `Temporal.ZonedDateTime` from a - * property-bag (year, month, day, …, timeZone, calendar). + * property-bag or ISO string. */ -export function toZonedDateTime(bag: Temporal.ZonedDateTimeLike & { timeZone: Temporal.TimeZoneLike, calendar?: Temporal.CalendarLike }): Temporal.ZonedDateTime { +export function toZonedDateTime(bag: Temporal.ZonedDateTimeLike | string, tz: Temporal.TimeZoneLike = 'UTC'): Temporal.ZonedDateTime { + if (isString(bag)) { + // Detect existing zone designator: bracketed IANA zone ([...]) or numeric offset (±HH:MM, Z) + const hasZone = /\[[^\]]+\]|([+-]\d{2}(:?\d{2})?|Z)$/.test(bag); + return Temporal.ZonedDateTime.from(hasZone ? bag : `${bag}[${tz}]`); + } return Temporal.ZonedDateTime.from(bag); } @@ -108,15 +113,40 @@ export function toInstant(epochNanoseconds: bigint): Temporal.Instant { /** * ## getTemporalIds * Normalize TimeZone and Calendar inputs into a [timeZoneId, calendarId] tuple. + * Accepts either (tz, cal) strings or a single ZonedDateTime-like object. + * Supports both spec-final (flat) and V8 harmony (nested) structures. */ -export function getTemporalIds(tz: any, cal: any): [string, string] { - const rawTz = isString(tz) ? tz : ((tz as any)?.timeZoneId ?? (tz as any)?.id); - const rawCal = isString(cal) ? cal : ((cal as any)?.calendarId ?? (cal as any)?.id); +export function getTemporalIds(zdt: Temporal.ZonedDateTime, cal?: Temporal.CalendarLike): [string, string]; +export function getTemporalIds(tz: Temporal.TimeZoneLike, cal?: Temporal.CalendarLike): [string, string]; +export function getTemporalIds(tzOrZdt: any, cal?: any): [string, string] { const fallbackTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - const tzId = (isString(rawTz) && rawTz.trim().length > 0) ? rawTz : fallbackTz; - const calId = (isString(rawCal) && rawCal.trim().length > 0) ? rawCal : 'iso8601'; - return [tzId || 'UTC', calId || 'iso8601']; + let rawTz: any, rawCal: any; + if (isZonedDateTime(tzOrZdt)) { + // If first arg is ZonedDateTime, use its IDs as source + rawTz = tzOrZdt.timeZoneId ?? tzOrZdt.timeZone?.id ?? tzOrZdt.timeZone; + // If a second argument is provided, it explicitly overrides the ZonedDateTime's calendar + rawCal = isDefined(cal) ? cal : (tzOrZdt.calendarId ?? tzOrZdt.calendar?.id ?? tzOrZdt.calendar); + } else { + rawTz = tzOrZdt; + rawCal = cal; + } + + // Helper to extract string ID from potential objects (TimeZone, Calendar, or ZonedDateTime) + const toId = (v: any): string => { + if (isString(v)) return v; + if (isZonedDateTime(v)) return toId((v as any).timeZoneId ?? (v as any).timeZone?.id ?? (v as any).timeZone); + if (isObject(v)) return String((v as any).id ?? (v as any).timeZoneId ?? (v as any).calendarId ?? ''); + return String(v ?? ''); + } + + const tzStr = toId(rawTz); + const calStr = toId(rawCal); + + const tzId = (tzStr.trim().length > 0 && tzStr !== '[object Object]' && tzStr !== 'undefined') ? tzStr : fallbackTz; + const calId = (calStr.trim().length > 0 && calStr !== '[object Object]' && calStr !== 'undefined') ? calStr : 'iso8601'; + + return [tzId, calId]; } /** diff --git a/packages/library/src/common/temporal.polyfill.ts b/packages/library/src/common/temporal.polyfill.ts index baa8fb3..1519a06 100644 --- a/packages/library/src/common/temporal.polyfill.ts +++ b/packages/library/src/common/temporal.polyfill.ts @@ -16,4 +16,19 @@ if (typeof globalThis.Temporal === 'undefined') { ); } +// 🛡️ Sane Implementation Check +// Some early native implementations (e.g. Node 22.0.x) are incomplete and crash on basic arithmetic. +// If you encounter "unimplemented code" or V8_Fatal crashes, manually load a polyfill before Tempo. +try { + // Minimal test for a feature known to be a stub in early implementations + if (typeof Temporal.Now.zonedDateTimeISO === 'function') { + const zdt = Temporal.Now.zonedDateTimeISO(); + if (typeof zdt.add !== 'function') throw new Error('Incomplete Temporal implementation'); + } +} catch (err: any) { + console.warn('Tempo: Native Temporal implementation appears incomplete. Consider loading a polyfill.', err); + if (err?.message !== 'Incomplete Temporal implementation') + throw err; +} + export { } diff --git a/packages/library/test/common/temporal_library.test.ts b/packages/library/test/common/temporal_library.test.ts new file mode 100644 index 0000000..5d1dfb6 --- /dev/null +++ b/packages/library/test/common/temporal_library.test.ts @@ -0,0 +1,29 @@ +import { toZonedDateTime } from '#library/temporal.library.js'; + +describe('Temporal Library Helpers', () => { + describe('toZonedDateTime', () => { + it('should append [tz] to plain ISO strings', () => { + const zdt = toZonedDateTime('2024-01-01T12:00:00', 'Australia/Sydney'); + expect(zdt.timeZoneId).toBe('Australia/Sydney'); + expect(zdt.toString()).toContain('[Australia/Sydney]'); + }); + + it('should NOT append [tz] to strings that already have a bracketed zone', () => { + const zdt = toZonedDateTime('2024-01-01T12:00:00[UTC]', 'Australia/Sydney'); + expect(zdt.timeZoneId).toBe('UTC'); + expect(zdt.toString()).not.toContain('[Australia/Sydney]'); + }); + + it('should NOT append [tz] to strings that have both an offset and a bracket', () => { + const input = '2024-01-01T12:00:00+11:00[Australia/Sydney]'; + const zdt = toZonedDateTime(input, 'UTC'); + expect(zdt.timeZoneId).toBe('Australia/Sydney'); + expect(zdt.toString()).not.toContain('[UTC]'); + }); + + it('should handle "Z" as a zone designator and pass it through (even if ZonedDateTime.from throws without a bracket)', () => { + const bag = '2024-01-01T12:00:00Z'; + expect(() => toZonedDateTime(bag, 'Australia/Sydney')).toThrow(/requires a time zone ID in brackets/); + }); + }); +}); diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 34610d0..7a78bd0 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -126,15 +126,15 @@ export default defineConfig({ }, { find: /^@magmacomputing\/tempo\/parse$/, - replacement: fileURLToPath(new URL('../dist/discrete/discrete.parse.js', import.meta.url)) + replacement: fileURLToPath(new URL('../dist/module/module.parse.js', import.meta.url)) }, { find: /^@magmacomputing\/tempo\/format$/, - replacement: fileURLToPath(new URL('../dist/discrete/discrete.format.js', import.meta.url)) + replacement: fileURLToPath(new URL('../dist/module/module.format.js', import.meta.url)) }, { - find: /^@magmacomputing\/tempo\/discrete$/, - replacement: fileURLToPath(new URL('../dist/discrete/discrete.index.js', import.meta.url)) + find: /^@magmacomputing\/tempo\/module$/, + replacement: fileURLToPath(new URL('../dist/module/module.index.js', import.meta.url)) }, { find: /^@magmacomputing\/tempo$/, diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 10d538a..fa957aa 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.9.2] - 2026-05-08 + +### Added +- **Resilient ID Extraction**: Unified `timeZoneId` and `calendarId` extraction into a single spec-resilient helper `getTemporalIds`. This ensures 100% compatibility across both spec-final and Node.js V8 harmony environments by resolving nested property drift (`timeZone.id` vs `timeZoneId`). +- **Identity-Based Layout Resolution**: Hardened `resolveLayoutClassificationOrder` to support identity-based symbol lookups. This ensures that tokens without descriptions or aliases (such as raw symbols) can be correctly prioritized in preferred layout ordering. +- **Named Capture for Separators**: Updated the default `{sep}` snippet to use a named capture group `(?...)`, improving the inspectability of generated regex patterns. + +### Changed +- **Non-Recursive Bootstrap**: Hardened the `toNow()` lifecycle and `today` alias to safely access local configuration without triggering circular parsing dependencies. +- **Modular Decompression**: Removed the redundant `parse.layout.ts` re-export module and consolidated all layout resolution logic into `engine.layout.ts`. Updated internal Specifiers and test-aliases to point to the new canonical home. +- **Node.js Harmony Support**: Updated documentation to highlight native `Temporal` support in Node.js 20+ via the `--harmony-temporal` flag, reducing the need for external polyfills in modern server-side environments. + +### Fixed +- **MasterGuard Validation**: Improved the `MasterGuard` scanner to correctly identify and reject whitespace-only strings by implementing explicit match tracking. +- **Symbol Mapping Safety**: Fixed a potential `TypeError` in `AliasEngine` when mapping Symbols without descriptions by hardening the `wordsList` creation logic. +- **Utility Security Hardening**: Refactored the `create` and `setPatterns` utilities with robust prototype-shadowing guards. These improvements prevent `TypeError` crashes when interacting with null-prototype objects and guarantee `PatternCompiler` state isolation across concurrent Tempo instances. +- **PatternCompiler Isolation**: Refactored `Tempo.regexp()` to guarantee `PatternCompiler` isolation per-state, preventing unintended cache leakage across inherited registries. +- **UI Accessibility**: Updated documentation button styles to use theme variables, ensuring WCAG 2.1 contrast compliance (4.5:1) for all brand elements. +- **RegExp Preview Accuracy**: Corrected the documentation example for `Tempo.regexp()` to accurately reflect the anchored outer capture group and unique named snippet expansions (`sep`, `sep_1`) produced by the engine. + +## [2.9.1] - 2026-05-07 + +### Fixed +- **Support Utility Consolidation**: Completed the rename and migration of internal support utilities to the `@packages/tempo/src/support/` directory. +- **Pattern Compiler isolated test state**: Fixed state-leakage in `pattern_compiler_optimization.test.ts` by implementing `TempoRuntime.createScoped()` and `init({}, false)` within `beforeEach` hooks. + ## [2.9.0] - 2026-05-06 ### Added diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index ff377c6..9495c03 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -124,13 +124,13 @@ For more implementation details, see [Soft Freeze Strategy](./soft_freeze_strate ## ⚡ 3. Master Guard (Guarded-Lazy Strategy) Used for: `new Tempo(string | number)` -The **Guarded-Lazy** strategy ensures that even with hundreds of custom plugins, the entry point remains nearly instantaneous. In **v2.0.1**, this was refined for 100% matching reliability. +The **Guarded-Lazy** strategy ensures that even with hundreds of custom plugins, the entry point remains nearly instantaneous. As of **v2.9.2**, this logic is decoupled into a dedicated `engine.guard.ts` module. ### How it works: -1. **Longest-Token Matching**: To prevent partial matching (e.g., matching `qtr` inside `quarter`), the guard uses a "Scan-and-Consume" loop that prioritizes the longest available token. +1. **Longest-Token Matching**: To prevent partial matching (e.g., matching `qtr` inside `quarter`), the guard uses a greedy "Scan-and-Consume" loop that prioritizes the longest available token. 2. **Unified Wordlist**: The guard automatically ingests all registered Terms, Timezones, Month names, and Custom Events into a single high-speed lookup Set. -3. **High-Speed Gatekeeper**: By avoiding complex backtracking regexes, the gatekeeper provides predictable $O(1)$ performance even as the plugin list grows. -4. **Versioned Registry (v2.9.0)**: To avoid redundant wordlist rebuilding, the Guard now monitors a `#version` counter on the alias registry. The wordlist is only rebuilt when a mutation actually occurs. +3. **High-Speed Gatekeeper**: By avoiding complex backtracking regexes, the gatekeeper provides predictable $O(1)$ performance regardless of how many plugins are registered. +4. **Versioned Registry**: To avoid redundant wordlist rebuilding, the Guard monitors a version counter on the alias registry. The wordlist is only rebuilt when a mutation actually occurs. 5. **Auto-Lazy**: Valid inputs that pass the guard automatically switch the instance to `mode: 'defer'`, deferring the full $O(N)$ parse work until a property is actually read. --- diff --git a/packages/tempo/doc/installation.md b/packages/tempo/doc/installation.md index 05b4d7b..570786f 100644 --- a/packages/tempo/doc/installation.md +++ b/packages/tempo/doc/installation.md @@ -10,7 +10,11 @@ As of 13 January 2026, Chrome 144 has shipped `Temporal`, and Firefox 139 also includes native `Temporal` support. -While Node.js does not yet enable `Temporal` by default, recent versions (Node 20+) support it via the `--harmony-temporal` flag. This allows you to use `Tempo` without an external polyfill package. +While Node.js does not yet enable `Temporal` by default, recent versions (Node 20+) support it via the `--harmony-temporal` flag (or `--js-temporal` in newer builds). This allows you to use `Tempo` without an external polyfill package. + +::: warning +Native implementations in Node.js are currently considered experimental and may be incomplete or contain bugs that cause unexpected crashes (e.g., `V8_Fatal` errors in some builds). For mission-critical stability, we strongly recommend using `@js-temporal/polyfill`. +::: Please verify support in your actual target runtime(s) and add a polyfill only when needed. @@ -51,6 +55,9 @@ If you are using Node.js 20+, you can enable native `Temporal` support without i node --harmony-temporal my-app.js ``` +> [!WARNING] +> Use native support with caution. Some Node.js builds contain incomplete Temporal implementations that can crash on complex arithmetic. See [Temporal Polyfill Note](#temporal-polyfill-note) for details. + ### Node.js (with Polyfill) The polyfill import shown here is conditional guidance, not required for all environments. diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index 3489b6b..b1e33cb 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,5 +1,21 @@ # 📜 Version 2.x History +## [v2.9.2] - 2026-05-08 +### New Features +- Enhanced Temporal support with improved timezone and calendar handling +- Added documentation for prototyping with regexp patterns +- Expanded installation guidance for Node.js Temporal support +### Bug Fixes +- Improved parsing robustness with better symbol mapping and prototype-shadowing fixes +- Enhanced identity-based layout resolution +- Strengthened lifecycle handling for timezone and calendar operations +### Improvements +- Optimized performance with dedicated guard and normalizer modules +- Updated UI styling with theme-aware color variables +- Refined error handling in engine components +### Documentation +- Expanded main branch protection guidance +- Enhanced architecture documentation with modular design overview ## [v2.9.0] - 2026-05-06 ### 🏗️ Alias Architecture Stabilization diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 3f19231..d0968aa 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -150,6 +150,9 @@ Tempo.init({ | `mode` | `'auto' \| 'strict' \| 'defer'` | `'auto'` | Controls the hydration strategy (e.g., `defer` for Zero-Cost creation). | | `silent` | `boolean` | `false` | Suppresses console output. Combined with `catch: true` for silent failover. | | `ignore` | `string \| string[]` | `['at']` | List of noise words to be stripped before parsing. | +| `layoutOrder` | `string[]` | Built-in Order | The sequence in which layouts are attempted during parsing. | +| `preFilter` | `boolean` | `false` | Enables the Parse Planner to skip irrelevant layouts based on input classification. | +| `planner` | `PlannerOptions` | `undefined` | Grouped configuration for `layoutOrder` and `preFilter`. | --- @@ -237,6 +240,36 @@ console.log(t.toString()); // Resolved correctly (noise words stripped) ::: +--- + +### 🚀 5.4 Parse Planner & Pre-filtering + +For high-performance applications, you can enable the **Parse Planner** to optimize the pattern-matching loop. + +#### `preFilter` (Boolean) +When enabled, Tempo performs a fast upfront classification of the input string (detecting digits, letters, colons, etc.) and skips layouts that cannot possibly match. + +- **Purely numeric inputs**: Skips `event`, `period`, `wkd`, and `rel` layouts. +- **Alpha-only inputs**: Skips time-heavy layouts like `hms` or `off`. +- **Colon detected**: Prioritizes time-based layouts (`tm`, `dtm`) to find a match faster. + +```javascript +Tempo.init({ preFilter: true }); +``` + +#### `layoutOrder` (Array) +You can manually define the order in which layouts are attempted. This is useful if you know your data primarily uses a specific format (e.g., ISO dates) and want to avoid checking other layouts first. + +```javascript +Tempo.init({ + layoutOrder: ['ymd', 'dt', 'tm', 'rel'] +}); +``` + +::: tip +**Observability**: Set `debug: true` along with `preFilter: true` to see a detailed "Planner summary" in the console, showing how many layouts were skipped for a given input. +::: + --- ## 📊 Summary of Tiers diff --git a/packages/tempo/doc/tempo.layout.md b/packages/tempo/doc/tempo.layout.md index d1fecea..1f2fbbb 100644 --- a/packages/tempo/doc/tempo.layout.md +++ b/packages/tempo/doc/tempo.layout.md @@ -90,7 +90,7 @@ You can use the static `Tempo.regexp()` method to "preview" how a layout string const regex = Tempo.regexp('{dd}{sep}{mm}{sep}{yy}'); console.log(regex.source); -// Output (illustrative): ^((?
...)(?...)(?...)(?...)(?...))$ +// Output (illustrative): ^((?
...)(?...)(?...)(?...)(?...))$ ``` --- diff --git a/packages/tempo/index.md b/packages/tempo/index.md index 83be48f..8bfed36 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -556,8 +556,8 @@ function focusActiveCard() { } .tempo-btn-brand { - background-color: #3498db; - color: white; + background-color: var(--vp-c-brand-1); + color: var(--vp-c-white); } .tempo-btn-brand:hover { background-color: #2980b9; diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 5785a3c..2307383 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.9.1", + "version": "2.9.2", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -104,16 +104,16 @@ "default": "./dist/support/*.js" }, "#tempo/parse": { - "development": "./src/discrete/discrete.parse.ts", - "default": "./dist/discrete/discrete.parse.js" + "development": "./src/module/module.parse.ts", + "default": "./dist/module/module.parse.js" }, "#tempo/format": { - "development": "./src/discrete/discrete.format.ts", - "default": "./dist/discrete/discrete.format.js" + "development": "./src/module/module.format.ts", + "default": "./dist/module/module.format.js" }, - "#tempo/discrete": { - "development": "./src/discrete/discrete.index.ts", - "default": "./dist/discrete/discrete.index.js" + "#tempo/module": { + "development": "./src/module/module.index.ts", + "default": "./dist/module/module.index.js" }, "#tempo/*.js": { "development": "./src/*.ts", @@ -161,17 +161,13 @@ "types": "./dist/module/module.mutate.d.ts", "import": "./dist/module/module.mutate.js" }, - "./discrete": { - "types": "./dist/discrete/discrete.index.d.ts", - "import": "./dist/discrete/discrete.index.js" - }, "./format": { - "types": "./dist/discrete/discrete.format.d.ts", - "import": "./dist/discrete/discrete.format.js" + "types": "./dist/module/module.format.d.ts", + "import": "./dist/module/module.format.js" }, "./parse": { - "types": "./dist/discrete/discrete.parse.d.ts", - "import": "./dist/discrete/discrete.parse.js" + "types": "./dist/module/module.parse.d.ts", + "import": "./dist/module/module.parse.js" }, "./library": { "types": "./dist/library.index.d.ts", @@ -201,6 +197,7 @@ "test:ci": "cross-env TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 vitest run", "test:ci:prefilter": "cross-env TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TEMPO_PREFILTER_CI=true vitest run", "repl": "tsx --conditions=development -i --harmony-temporal --import ./bin/repl.ts", + "safe": "tsx --conditions=development -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", "bare": "tsx --conditions=development -i --harmony-temporal", "core": "cross-env TEMPO_LITE=true tsx --conditions=development -i --harmony-temporal --import ./bin/core.ts", "parse": "cross-env TEMPO_LITE=true tsx --conditions=development -i --harmony-temporal --import ./bin/parse.ts", @@ -224,9 +221,8 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.9.1", + "@magmacomputing/library": "2.9.2", "@rollup/plugin-alias": "^6.0.0", - "cross-env": "^7.0.3", "magic-string": "^0.30.21", "markdown-it-mathjax3": "^4.3.2", "typedoc": "^0.28.19", diff --git a/packages/tempo/plan/.WISHLIST.md b/packages/tempo/plan/.WISHLIST.md deleted file mode 100644 index 24c7d41..0000000 --- a/packages/tempo/plan/.WISHLIST.md +++ /dev/null @@ -1,294 +0,0 @@ -# Tempo v2.0.2 Wishlist (Post-Lockdown) - -## Architecture & Design -- [ ] **Investigate Code-Smells**: Review complex methods (like `#parse`) for high cyclomatic complexity and potential refactoring. - -## Pervasive Hard-Freeze Proxies -We should refactor our core "securing" mechanisms to return our throwing Proxies instead of just freezing the object. This ensures that every "Immutable" object in the Tempo ecosystem—from the main Tempo instance to the tiny ResolvedRange objects—will loudly complain if someone tries to mutate them in the REPL. - -- Pervasive Hard-Freeze Proxies -This plan refactors the library's immutability system to use throwing Proxies instead of native `Object.freeze`. This ensures that all immutable objects provide a consistent "throw-on-mutation" experience, even in non-strict environments like the Node.js REPL. -- User Review Required -> [!IMPORTANT] -> Transitioning from `Object.freeze` to Proxies for all immutable objects is a significant architectural shift. While it provides the best possible UX for developers, it may have minor performance implications and identity-check considerations (e.g., `proxy === target` is false). However, given Tempo's focus on safety and developer experience, this is the recommended path. -- Proposed Changes -- [Component] Library (Utility & Class Decorators) -- [MODIFY] [utility.library.ts](file:///home/michael/Project/magma/packages/library/src/common/utility.library.ts) -- Update `secure()` to wrap the final frozen object in a `proxify()` call. -- This ensures that resolving a term (like `t.term.qtr`) returns a Proxy that throws on mutation. -- [MODIFY] [class.library.ts](file:///home/michael/Project/magma/packages/library/src/common/class.library.ts) -- Update the `@Immutable` decorator to return `proxify(this)` from the constructor. -- This ensures that every `Tempo` instance is a Proxy that throws on mutation. -- [Component] Library (Proxy System) -- [MODIFY] [proxy.library.ts](file:///home/michael/Project/magma/packages/library/src/common/proxy.library.ts) -- Refine error messages to distinguish between "Frozen Objects" (instances) and "Read-Only Delegators" (terms/formats). -- Ensure all mutation traps (including `defineProperty` and `deleteProperty`) are robustly covered. -- Verification Plan -- Manual Verification (REPL) -- Start the REPL: `npm run repl`. -- Verify the following all throw explicit `TypeErrors`: - - `delete t1.term` - - `t1.term = {}` - - `t1.term.qtr = 1` - - `delete t1.term.quarter.month` - -## Parse: `layoutOrder` option — arbitrary layout sequencing - -Today the only reordering supported is the pairwise `mdyLayouts` swap for locale-based `dmy`↔`mdy` flipping. -There is no way for a developer to reorder layouts arbitrarily (e.g. promote `wkd` above `tm`, or demote `rel` entirely). - -Proposed: a `layoutOrder` option that accepts an ordered array of layout names and rebuilds the parse sequence accordingly. - -```ts -Tempo.init({ - layoutOrder: ['wkd', 'dt', 'dtm', 'tmd', 'tm', 'ymd', 'mdy', 'dmy', 'off', 'rel'] -}) -``` - -- The internal layout map is already built from `Object.entries()` so the infrastructure is ready. -- Any name not listed would retain its relative position at the end (non-destructive by default). -- Could be supplied at static-init level or per-instance level (same as `layout`/`snippet`). -- `mdyLayouts` swap would still apply on top, after the custom order is set. - -## Parse: input-class pre-filtering — conditional layout selection - -Today every layout pattern is tested against every input string in order, with no early-outs based on -the nature of the input. Proposed: a fast upfront classification of the raw input that gates which -layout subsets are even tried. - -**Critical constraint — numeric fall-through must be preserved:** -The parse pipeline already has two upstream numeric escapes that happen *before* the layout loop and -must never be disrupted: -- `isIntegerLike(trim)` — matches strings ending in `n` (e.g. `1234567890n`) → treated as BigInt - nanoseconds and returned immediately, bypassing all layouts. -- `type === 'Number'` (i.e. the input was supplied as a JS `number`) — goes through the layout loop - but is interpreted by the composer as epoch milliseconds if no layout matches. - -Any input-class pre-filtering must therefore be applied **only after** those two escapes have already -passed, and must not gate-out purely-numeric inputs from reaching the layout loop — because a numeric -string like `'20260424'` is a valid compact date and must be tried against layout patterns first. -The epoch-millisecond fallback is the *last resort*, not a skip condition. - -Example input classes and their natural consequences: - -| Input class | Layouts to skip | -|---|---| -| Purely numeric | `event`, `period`, `wkd`, `rel` — but NOT the layout loop itself | -| Contains only letters | `hms`, `dmy6`, `mdy6`, `ymd6`, `off` | -| Contains a colon | Bias time layouts first | -| Contains `ago`/`hence` | Jump straight to `rel` | -| Exactly 6 digits | Only try `hms`, `dmy6`, `mdy6`, `ymd6` | -| Exactly 8 digits | Only try compact date layouts | - -Benefits: -- Performance: fewer regex executions per parse call, particularly valuable for hot loops (Ticker, bulk formatting). -- Correctness: eliminates the `{hh}` partial-match latent trap for letter-containing inputs. -- The layout loop remains authoritative — this is transparent pre-filtering, not a replacement. - -Implementation note: input-class detection would run once per parse call using simple -`/^[0-9]+$/`, `/[a-zA-Z]/`, `.length` checks before the layout loop. Could be expressed -as a small decision table or a set of bit-flags (e.g. `hasDigits`, `hasLetters`, `hasColon`, -`hasSeparator`) computed in a single pass over the input string. - -## Further discrete-module candidates (post parse/format/duration/mutation split) - -The parse and format standalone split is already a major win. Additional high-ROI splits still available: - -- Parse Pipeline Planner module: - owns input classification, pre-filter policy, and candidate-layout selection. -- Layout Order Resolver module: - owns base order, locale `mdyLayouts` swaps, and future `layoutOrder` handling. -- Pattern Compiler + Cache module: - owns snippet/layout expansion, regex compilation, and cache invalidation. -- Config Option Mutation Engine module: - owns option-application switchboard (especially parse-related options). -- Alias Resolution Engine module: - owns event/period collision policy and snippet rebinding into layout-aware groups. - -Secondary extractions (lower urgency, still useful): - -- Guard Builder module: - owns token ingestion and fast-fail guard rebuild lifecycle. -- Parse Result Normalizer module: - owns match accumulation and parse-result shaping/trace output. - -## Release-boundary modularization roadmap - -Goal: execute all of the above, but spread refactors to minimize regression risk and preserve API stability. - -### Release A (next immediate release): low-risk, high-leverage foundations - -- [ ] Extract Layout Order Resolver (pure ordering logic only). -- [ ] Add test matrix for ordering determinism: - - base order unchanged - - mdy swap pairs still deterministic - - custom-inserted layouts maintain stable relative order -- [ ] Add telemetry/debug hook for resolved layout order (debug mode only). - -Exit criteria: -- No behavioral change except internally equivalent ordering. -- Existing full test suite green. - -### Release B: planner path and configuration surface - -- [ ] Extract Parse Pipeline Planner (candidate selection interface only). -- [ ] Implement `layoutOrder` option through Layout Order Resolver. -- [ ] Ensure `layoutOrder` composes safely with `mdyLayouts` (order then swap). -- [ ] Add docs and migration notes for ordering customization. - -Exit criteria: -- `layoutOrder` works for both global init and local instance options. -- Legacy behavior preserved when `layoutOrder` is omitted. - -### Release C: conditional pre-filtering optimization - -- [ ] Enable input-class pre-filtering in planner. -- [ ] Preserve numeric safety constraints: - - `isIntegerLike(trim)` remains an early BigInt nanosecond escape. - - Number-input epoch-millisecond fallback remains last-resort behavior. - - Numeric strings still run through layout matching first. -- [ ] Add perf benchmarks before/after for regex-attempt count and constructor parse latency. - -Exit criteria: -- Equal parse correctness against regression suite. -- Measurable reduction in unnecessary pattern checks. - -### Release D: deeper decomposition cleanup - ---- - -#### Parse Result Normalizer Extraction — Assessment Outline - -**Purpose:** -Evaluate the value and feasibility of extracting all logic related to match accumulation and parse-result shaping/trace output into a dedicated module. - -**Boundaries & Responsibilities:** -- Would own the process of normalizing parse results and shaping trace/debug output. -- Would expose APIs for result normalization and trace formatting. -- Should integrate with the main engine’s parse and debug systems. - -**Assessment Steps:** -1. Identify all result normalization and trace output logic in `tempo.class.ts` and helpers. -2. Determine if the logic is sufficiently complex or reused to justify extraction. -3. If justified, outline module boundaries and migration steps similar to previous extractions. -4. If not, document reasons for keeping logic inline. - -**Potential Affected Files:** -- `src/tempo.class.ts` -- `src/support/tempo.util.ts` -- `src/tempo.type.ts` - -**Risks & Mitigations:** -- Risk: Over-extraction of simple logic. Mitigation: Only extract if complexity or reuse warrants. -- Risk: Integration issues with parse/trace systems. Mitigation: Careful interface design and incremental refactor. - -**Expected Improvements (if extracted):** -- Cleaner separation of result normalization logic. -- Easier to test and update parse-result shaping and trace output. - -#### Alias Resolution Engine Extraction — Detailed Outline - -**Purpose:** -Modularize all logic related to event/period alias resolution, collision policy, and snippet rebinding into layout-aware groups for clarity, maintainability, and extensibility. - -**Boundaries & Responsibilities:** -- Accepts event/period definitions and manages alias mapping and collision detection. -- Handles rebinding of snippets into layout-aware groups. -- Exposes clear APIs for resolving aliases and reporting collisions. -- Integrates with the main engine to ensure correct event/period resolution during parsing. - -**Migration Steps:** -1. Identify and extract all alias resolution logic from `tempo.class.ts` and related helpers into a new module (e.g., `engine.alias.ts`). -2. Define clear interfaces for alias registration, lookup, and collision reporting. -3. Refactor the main engine and plugin system to use the new module’s APIs. -4. Add/expand unit tests for alias resolution, collision handling, and rebinding. -5. Document the new module’s API and update internal references. - -**Affected Files:** -- `src/tempo.class.ts` (extraction and refactor) -- `src/support/tempo.util.ts` (if helpers are involved) -- `src/tempo.type.ts` (type updates if needed) - -**Risks & Mitigations:** -- Risk: Incorrect alias resolution or missed collisions. Mitigation: Add focused unit tests and regression tests. -- Risk: Integration issues with plugin/event/period systems. Mitigation: Incremental refactor and thorough testing. - -**Expected Improvements:** -- Cleaner separation of concerns for alias logic. -- Easier to extend and maintain event/period handling. -- Improved testability and reliability of alias resolution. - - -#### Pattern Compiler + Cache Extraction Plan (to be detailed) - -*Purpose*: Modularize all logic related to snippet/layout expansion, regex compilation, and cache invalidation. - -*Outline (to be expanded):* -1. Identify all code responsible for pattern expansion and regex compilation. -2. Define clear module boundaries and interfaces for the compiler and cache. -3. Move related logic from the main engine/class to the new module. -4. Implement cache invalidation and update mechanisms. -5. Add focused unit tests for the new module. -6. Update documentation and internal references. - ---- - -#### Pattern Compiler + Cache Extraction — Detailed Outline - -**Purpose:** -Modularize all logic related to snippet/layout expansion, regex compilation, and pattern cache management for clarity, testability, and maintainability. - -**Boundaries & Responsibilities:** -- Accepts layout/snippet definitions and returns compiled RegExp objects. -- Handles recursive expansion of layout placeholders (e.g., `{yy}`, `{mm}`) using snippet registries. -- Manages a cache of compiled patterns for performance. -- Exposes cache invalidation/refresh methods for dynamic config changes. -- Provides a clear interface for the rest of the Tempo engine to request compiled patterns. - -**Migration Steps:** -1. Extract `compileRegExp`, `setPatterns`, and related helpers from `tempo.util.ts` into a new module (e.g., `pattern.compiler.ts`). -2. Move or wrap memoization/caching logic (from `function.library.ts`) as needed for pattern compilation. -3. Refactor `tempo.class.ts` and other consumers to use the new module’s interface. -4. Ensure all pattern/snippet/layout definitions in `tempo.default.ts` are compatible with the new module. -5. Add/expand unit tests for pattern expansion, compilation, and cache behavior. -6. Document the new module’s API and update internal references. - -**Affected Files:** -- `src/support/tempo.util.ts` (extraction) -- `src/tempo.class.ts` (refactor to use new module) -- `src/support/tempo.default.ts` (ensure compatibility) -- `src/tempo.type.ts` (type updates if needed) -- `library/src/common/function.library.ts` (cache/memoization logic) - -**Risks & Mitigations:** -- Risk: Subtle bugs in recursive expansion or cache invalidation. Mitigation: Add focused unit tests and regression tests. -- Risk: Performance regressions if cache is not used correctly. Mitigation: Benchmark before/after and optimize cache usage. - -**Expected Improvements:** -- Lower cyclomatic complexity in the main engine. -- Easier to test and reason about pattern expansion and compilation. -- Clearer cache management and invalidation. - -### Release D: Recommended Release Strategy - -To balance safety and efficiency: - -- Release the two major extractions (Pattern Compiler + Cache, Alias Resolution Engine) as separate point-releases for focused testing and easier rollback. -- Batch the assessment/documentation steps (Guard Builder, Parse Result Normalizer, affected files/modules, improvements/risks) into a single follow-up release if they are lightweight. - -This approach allows for incremental progress, clear regression points, and manageable review cycles. - -## Next sequence kickoff (start now) - -The recommended execution sequence begins here: - -1. Extract Layout Order Resolver. -2. Extract Parse Pipeline Planner shell (no filtering yet). -3. Wire `layoutOrder` option to resolver. -4. Add input-class pre-filtering behind a guarded internal flag. - -Implementation guardrails: - -- Keep each step behavior-preserving unless explicitly feature-gated. -- Land with dedicated tests in each step before moving to the next. -- Avoid combining planner + filtering + compiler extraction in one release. diff --git a/packages/tempo/plan/RELEASE-D.md b/packages/tempo/plan/RELEASE-D.md deleted file mode 100644 index 54e8017..0000000 --- a/packages/tempo/plan/RELEASE-D.md +++ /dev/null @@ -1,172 +0,0 @@ -# Release D: Deeper Decomposition Cleanup - -## Overview -This release focuses on modularizing and refactoring the parsing and pattern-matching internals of Tempo for improved maintainability, testability, and extensibility. The goal is to extract tightly-scoped modules for pattern compilation, alias resolution, guard building, and result normalization, with clear boundaries and robust test coverage. - -## Task Breakdown & Tracking - - -### Pattern Compiler + Cache Extraction -- [x] Extract `compileRegExp`, `setPatterns`, and helpers to new module (PatternCompiler) -- [x] Integrate memoization/caching logic as needed (PatternCompiler cache) -- [x] Refactor engine and consumers to use new PatternCompiler module -- [x] Ensure compatibility with snippet/layout definitions -- [x] Add/expand unit tests for pattern logic and cache -- [x] Update documentation and references - -### Alias Resolution Engine Extraction -- [x] Extract alias resolution logic to new module -- [x] Define interfaces for registration, lookup, collision -- [x] Refactor engine and plugins to use new APIs -- [x] Add/expand unit tests for alias/collision -- [x] Update documentation and references - -### Guard Builder Extraction (Assessment) -- [ ] Identify all guard-building/token-ingestion logic -- [ ] Assess complexity/reuse for extraction -- [ ] Outline module boundaries if justified -- [ ] Document reasons if not extracted - -### Parse Result Normalizer Extraction (Assessment) -- [ ] Identify all result normalization/trace logic -- [ ] Assess complexity/reuse for extraction -- [ ] Outline module boundaries if justified -- [ ] Document reasons if not extracted - -## Expected Improvements and Risks - -**Expected Improvements:** -- Lower cyclomatic complexity and improved maintainability in the main engine. -- Clearer separation of concerns between parsing, pattern compilation, alias resolution, guard building, and result normalization. -- Easier to test, extend, and debug individual modules. -- More robust and explicit cache management. -- Improved reliability and correctness through focused unit and regression tests. -- Smoother onboarding for new contributors due to modular structure and documentation. - -**Risks:** -- Potential for subtle integration bugs during refactor, especially in recursive expansion, alias resolution, or cache invalidation. -- Temporary performance regressions if cache or pattern compilation is not optimized. -- Over-extraction of simple logic could increase codebase complexity without clear benefit. -- Increased review and testing overhead for each extraction step. - -**Mitigations:** -- Incremental, well-documented releases with dedicated tests at each step. -- Benchmarking and profiling before/after major changes. -- Only extract modules where complexity or reuse justifies it. -- Maintain clear interfaces and documentation for all new modules. - -## Affected Files and Modules - -The following files and modules are likely to be affected by the decomposition and extractions in Release D: - -- `src/tempo.class.ts` — Main engine logic, source of most extraction candidates. -- `src/support/tempo.util.ts` — Utility functions for pattern, guard, and normalization logic. -- `src/support/tempo.default.ts` — Core snippet, layout, and pattern definitions. -- `src/tempo.type.ts` — Type definitions for parse, pattern, and result structures. -- `src/support/tempo.register.ts` — May require updates for cache/registry management. -- `library/src/common/function.library.ts` — Memoization and cache utilities. -- `src/parse/parse.layout.ts` — Layout order and planner logic (if not already modularized). -- Any new modules created for: Pattern Compiler + Cache, Alias Resolution Engine, Guard Builder, Parse Result Normalizer. -- Test files covering parsing, pattern matching, event/period handling, and normalization. - -## Detailed Outlines - -### Pattern Compiler + Cache Extraction — Detailed Outline -**Purpose:** -Modularize all logic related to snippet/layout expansion, regex compilation, and pattern cache management for clarity, testability, and maintainability. - -**Boundaries & Responsibilities:** -- Accepts layout/snippet definitions and returns compiled RegExp objects. -- Handles recursive expansion of layout placeholders (e.g., `{yy}`, `{mm}`) using snippet registries. -- Manages a cache of compiled patterns for performance. -- Exposes cache invalidation/refresh methods for dynamic config changes. -- Provides a clear interface for the rest of the Tempo engine to request compiled patterns. - -**Migration Steps:** -1. Extract `compileRegExp`, `setPatterns`, and related helpers from `tempo.util.ts` into a new module (e.g., `pattern.compiler.ts`). -2. Move or wrap memoization/caching logic (from `function.library.ts`) as needed for pattern compilation. -3. Refactor `tempo.class.ts` and other consumers to use the new module’s interface. -4. Ensure all pattern/snippet/layout definitions in `tempo.default.ts` are compatible with the new module. -5. Add/expand unit tests for pattern expansion, compilation, and cache behavior. -6. Document the new module’s API and update internal references. - -**Risks & Mitigations:** -- Risk: Subtle bugs in recursive expansion or cache invalidation. Mitigation: Add focused unit tests and regression tests. -- Risk: Performance regressions if cache is not used correctly. Mitigation: Benchmark before/after and optimize cache usage. - -**Expected Improvements:** -- Lower cyclomatic complexity in the main engine. -- Easier to test and reason about pattern expansion and compilation. -- Clearer cache management and invalidation. - -### Alias Resolution Engine Extraction — Detailed Outline -**Purpose:** -Modularize all logic related to event/period alias resolution, collision policy, and snippet rebinding into layout-aware groups for clarity, maintainability, and extensibility. - -**Boundaries & Responsibilities:** -- Accepts event/period definitions and manages alias mapping and collision detection. -- Handles rebinding of snippets into layout-aware groups. -- Exposes clear APIs for resolving aliases and reporting collisions. -- Integrates with the main engine to ensure correct event/period resolution during parsing. - -**Migration Steps:** -1. Identify and extract all alias resolution logic from `tempo.class.ts` and related helpers into a new module (e.g., `alias.engine.ts`). -2. Define clear interfaces for alias registration, lookup, and collision reporting. -3. Refactor the main engine and plugin system to use the new module’s APIs. -4. Add/expand unit tests for alias resolution, collision handling, and rebinding. -5. Document the new module’s API and update internal references. - -**Risks & Mitigations:** -- Risk: Incorrect alias resolution or missed collisions. Mitigation: Add focused unit tests and regression tests. -- Risk: Integration issues with plugin/event/period systems. Mitigation: Incremental refactor and thorough testing. - -**Expected Improvements:** -- Cleaner separation of concerns for alias logic. -- Easier to extend and maintain event/period handling. -- Improved testability and reliability of alias resolution. - -### Guard Builder Extraction — Assessment Outline -**Purpose:** -Evaluate the value and feasibility of extracting all logic related to token ingestion and fast-fail guard rebuild lifecycle into a dedicated module. - -**Boundaries & Responsibilities:** -- Would own the process of ingesting tokens and rebuilding fast-fail guards for parsing. -- Would expose APIs for guard construction, update, and validation. -- Should integrate with the main engine’s parse pipeline and pattern system. - -**Assessment Steps:** -1. Identify all guard-building and token-ingestion logic in `tempo.class.ts` and helpers. -2. Determine if the logic is sufficiently complex or reused to justify extraction. -3. If justified, outline module boundaries and migration steps similar to previous extractions. -4. If not, document reasons for keeping logic inline. - -**Risks & Mitigations:** -- Risk: Over-extraction of simple logic. Mitigation: Only extract if complexity or reuse warrants. -- Risk: Integration issues with parse pipeline. Mitigation: Careful interface design and incremental refactor. - -**Expected Improvements (if extracted):** -- Cleaner separation of guard logic. -- Easier to test and update guard-building behavior. - -### Parse Result Normalizer Extraction — Assessment Outline -**Purpose:** -Evaluate the value and feasibility of extracting all logic related to match accumulation and parse-result shaping/trace output into a dedicated module. - -**Boundaries & Responsibilities:** -- Would own the process of normalizing parse results and shaping trace/debug output. -- Would expose APIs for result normalization and trace formatting. -- Should integrate with the main engine’s parse and debug systems. - -**Assessment Steps:** -1. Identify all result normalization and trace output logic in `tempo.class.ts` and helpers. -2. Determine if the logic is sufficiently complex or reused to justify extraction. -3. If justified, outline module boundaries and migration steps similar to previous extractions. -4. If not, document reasons for keeping logic inline. - -**Risks & Mitigations:** -- Risk: Over-extraction of simple logic. Mitigation: Only extract if complexity or reuse warrants. -- Risk: Integration issues with parse/trace systems. Mitigation: Careful interface design and incremental refactor. - -**Expected Improvements (if extracted):** -- Cleaner separation of result normalization logic. -- Easier to test and update parse-result shaping and trace output. diff --git a/packages/tempo/plan/alias-migration-phase2.md b/packages/tempo/plan/alias-migration-phase2.md deleted file mode 100644 index d506b06..0000000 --- a/packages/tempo/plan/alias-migration-phase2.md +++ /dev/null @@ -1,51 +0,0 @@ -# Alias Migration: Phase 2 - Full Resolution Engine - -This document outlines the remaining tasks to complete the migration from legacy alias management to the centralized `AliasEngine` architecture. The goal is to move all interpretation and mutation logic out of the Parser and into the Engine. - -## 1. Consolidate Resolution Context (The "Host" Object) -Currently, `discrete.parse.ts` manually constructs a "pseudo-Tempo" `host` object to pass into functional aliases. This logic should be standardized and moved to a helper. - -- [x] Create a `getResolutionContext(state, dateTime)` helper in `support` or `AliasEngine`. -- [x] Ensure the context provides `add`, `subtract`, `with`, `set`, and time-unit accessors. -- [x] Remove the manual host construction from `discrete.parse.ts`. - -## 2. Hardened Clock Snapping -Aliases that resolve to a time-string (`hh:mm[:ss]`) currently have two different paths depending on whether they are static or functional. - -- [x] **Standardize Paths**: Both static and functional aliases should trigger the "snap" path if they match `Match.clock`. -- [x] **Fix Precision Leak**: Ensure that snapping to a clock-time clears ALL sub-second components (ms, us, ns) from the anchor. -- [x] **Support High-Precision**: Update the snapping logic to support `hh:mm:ss.ffffff` patterns natively. -- [x] **Engine-Level Detection**: Move the `Match.clock` test into `AliasEngine.resolveAlias`. - -## 3. Rich Alias Results -Instead of returning a raw `string | number`, the `AliasEngine` should return a structured result object. - -- [x] Define `AliasResult` interface: - ```typescript - interface AliasResult { - value: string; - key: string; // The original baseName (e.g., 'noon') - type: 'evt' | 'per'; - source: 'global' | 'local'; - isClock: boolean; // True if it matched Match.clock - isFunction: boolean; - } - ``` -- [x] Update `resolveAlias` to return this structure. - -## 4. Parser Cleanup -With the Engine handling the "what" and "how" of resolution, the Parser can focus on the "when". - -- [x] Refactor `parseGroups` in `discrete.parse.ts` to consume the new `AliasResult`. -- [x] Remove manual string-splitting and mutation logic from the Parser. -- [x] Leverage the `source` metadata from the result instead of manually parsing regex group names (like `evt1_0`). -- [x] Extract `host` context construction to a helper. - -## 5. Lifecycle & Monitoring -- [x] Implement `AliasEngine.getVersion()` or similar to allow `Tempo` class to detect registry changes without deep-cloning. -- [x] Audit `tempo.class.ts` for any remaining direct access to `parse.event` or `parse.period`. - ---- - -> [!IMPORTANT] -> **Priority 1**: Hardening the clock-snapping logic and fixing the sub-second precision leak. diff --git a/packages/tempo/plan/alias-precedence-strategy.md b/packages/tempo/plan/alias-precedence-strategy.md deleted file mode 100644 index f0d8a7e..0000000 --- a/packages/tempo/plan/alias-precedence-strategy.md +++ /dev/null @@ -1,57 +0,0 @@ -# Alias Precedence Strategy (Custom Over Built-in) - -## Context -A parser conflict was identified where a user-defined alias (`half-hour`) did not override the built-in pattern (`half[ -]?hour`). - -Implemented behavior now gives user-defined `event` and `period` aliases precedence over existing built-ins by placing incoming aliases first in evaluation order. - -## Risks Of Custom-First Precedence -1. Behavioral change risk - - Existing consumers that relied on built-ins winning may observe changed parse results. - -2. Pattern overlap ambiguity - - Regex-like aliases can overlap in non-obvious ways, making the selected winner surprising. - -3. Global side effects - - A single custom alias can change parsing behavior globally after `Tempo.init()` or plugin registration. - -4. Ordering sensitivity - - If precedence is based only on merge order, results can vary depending on discovery/store/options composition. - -## Why Not Use Symbol Keys For Public Aliases -Using `Symbol` for alias keys is not recommended as a public API: -- Alias definitions are string/regex patterns and must be converted into regex groups. -- Discovery and storage payloads are JSON/string keyed; Symbols are not portable in that flow. -- Symbols reduce inspectability and debuggability for users. - -Recommended: keep string keys for matching, optionally use internal metadata (including Symbols if desired) for identity bookkeeping only. - -## Current Mitigation Implemented -1. Custom aliases are evaluated before existing aliases. -2. Collision warnings are emitted when an incoming alias appears to overlap an existing alias pattern. - -## Recommended Follow-Up Improvements -1. Add explicit priority metadata - - Introduce structured alias records with fields like `source`, `priority`, and `insertionIndex`. - - Suggested default ordering: custom > plugin > built-in. - -2. Add strict conflict mode - - Optional config mode that throws on ambiguous overlaps instead of only warning. - -3. Improve collision diagnostics - - Include winning and losing aliases and origin (`builtin/custom/plugin`) in warning messages. - -4. Add deterministic override tests - - Custom exact override over built-in regex. - - Plugin override over built-in. - - Non-overlapping aliases remain stable. - - Strict mode throws on overlap. - -5. Add canonicalization policy (optional) - - Consider normalizing common punctuation/spacing variants for consistency. - - Only apply if it does not break existing regex-driven alias power. - -## Practical Guidance For Future Alias Additions -- Prefer specific patterns over broad regexes. -- Avoid introducing aliases that can match common built-in forms unless override is intentional. -- When overriding a built-in alias, add tests that assert both match winner and output behavior. diff --git a/packages/tempo/plan/alias.registration.md b/packages/tempo/plan/alias.registration.md deleted file mode 100644 index 5f256fa..0000000 --- a/packages/tempo/plan/alias.registration.md +++ /dev/null @@ -1,68 +0,0 @@ -tempo.class will invoke $setEvents() as part of the global, sandbox and instance setup. -events will be an array of [eventName, eventTarget] -where eventName is a plain string or a regex-like string (e.g. "xmas( )?eve"), and eventTarget is a string or Function that returns a string. -the eventTarget will name the date-string (e.g. "25-Dec") that should be interpreted when this eventName is detected by the parse-engine. - -$setEvents will then run "events=ownEntries();" on the list of Events it has been provided (most likely just the Default, but could be more from global-discovery, localStorage, etc.). - -If there are no events (which can happen), $setEvents exits... nothing to do. - -If there are events, it should - check if there is an 'own' shape.aliasEngine, else allocate a 'new AliasEngine(...)" -the new aliasEngine should contain a reference to it's parent object... nothing for global, global for sandbox, global-or-sandbox for instances. -This hierarchy is important for Event resolution (see below). -each new aliasEngine should calculate it's own 'depth'... that is, - global => 0, - sandbox => 1+ (increasing for each sandbox created from another sandbox), - instance => 1 (if direct child of global) or => 2+ (if direct child of a sandbox) - -$setEvents should then call aliasEngine.clear('event')... not sure if this is absolutely necessary, but couldn't hurt. - -$setevents should then call "const groups = aliasEngine.registerEvents(events);" -to pass control to the shape's aliasEngine instance. - -That instance will go through the 'events' array, and for each: - stash some related information into the Engine's instance so we can track - ### a sequential number to be allocated on an Event - ### the baseName - ### the eventTarget - ### the eventName (? not sure if this is needed ?) - a Set on the instance will track calc'd 'baseName' - it will also output a 'warn' if it detects that a baseName has already been used (whether in the current events-array or up the proto-chain). - -Once the registration process is complete, it should return a regex-like string back to the caller in tempo.class. -The string will contain (from lowest to highest in the proto-chain) a calculated named-group regex source, with "(?eventTarget)" - the section will be "{depth}evt{index}" where depth is the aliasEngine's instance depth (0, 1, 2, etc.) and index is the sequential number that was assigned to an Event. - For example, passing in ['xmas','25-Dec'] from the global shape will have the registration return "((?<0evt1>xmas))" - For example, later passing in [bday; '20-May'] from an instance shape will have the registration return "((?<1evt1>bday)|(?<0evt1>xmas))" - -When assembling the string to be returned (pipe-delimited named-group regex-source), the registration should: - ensure lower-depth regex-sources are returned prior to higher-depth - ensure that if a lower-depth is marked as a 'collision', then any higher-depth with that same baseName will be excluded - -To use a Period as an example, assuming an instance wants to override a global definition of 'noon': - "new Tempo('noon': {period: {noon:'11:00'}}); - We would expect the depth for the Tempo-instance to be '1' (direct child of global shape) - We would expect the index to be '1' (first Period alias detected) - We would expect the registerEvents to return "((?<1per1>noon)|... the rest of the global Periods *except* where baseName is 'noon')" - -When the calculated alias-string is returned to tempo.class, it will then update it's shadow the definition of the parent's snippets for Token.evt and Token.per. -tempo.class then calls setPatterns which will build the actual patterns (based on the current Layouts / Snippets) - -## Event Resolution -when the parsing engine detects a match against the patterns, and it finds a named-group with the pattern evt or per, then it knows it has an alias to de-reference. - -It will find the aliasEngine that is associated with the current tempo-level being parse (global, sandbox, instance). - -It will then invoke that aliasEngine's instance's method resolveEvent (or resolvePeriod) by passing in the named-group and the 'this' reference. - -The aliasEngine will decode the 'depth' from the alias argument (the leading digits before the 'evt' or 'per' portion of the string), and travel up the proto-chain til it finds the correct instance that matches that depth. - -The aliasEngine will then decode the 'index' from the alias argument (the trailing digit after the 'evt' or 'per' portion of the string) - -That resolved instance will lookup its own registry of Aliases for the index of the eventTarget. - -If the retrieved eventTarget is a string, it will return it to the parsing engine. -If the retrieved eventTarget is a Function, it will invoke the function (binding the 'this' context), and invoke a .toString() on the result before passing it back to the eventTarget;' - -* what to do if the alias resolution is cyclic ? diff --git a/packages/tempo/plan/configuration.md b/packages/tempo/plan/configuration.md deleted file mode 100644 index 43e1d0a..0000000 --- a/packages/tempo/plan/configuration.md +++ /dev/null @@ -1,190 +0,0 @@ -We ran into a problem with one of our test-cases yesterday. - -The test-case was: 'dynamic period alias with `this` binding (e.g. half-hour)' -Tempo.init({ - period: { - 'half-hour': function (this: Tempo) { - return `${this.hh}:30` - } - } - }) - const t = new Tempo('half-hour') - expect(t.format('{mi}:{ss}')).toBe('30:00') - expect(t.hh).toBe(new Tempo().hh) - -The Error: Actual did not match Expected. - -// -The issue turned out to be a clash in alias naming... -1) There is an Period alias registered already in tempo.default -{'half[ -]?hour': function (this: Tempo) { - return this.add({ minutes: 30 }); - }} - -2) This clashed with the instance-specific definition in the test-case. - -3) The fix was to allow instance-specific definitions to override global definitions. - -// -But this raises a bigger issue: what to do with name-clashes. -This discussion document explores that topic, focussing mainly on Event and Period aliases: -"Should Tempo allow name-clashes?" -"What to do when it detects one?" - -Our choices are "last one wins", or "first one wins", or "error out". - -At first blush, it seems to me that "error out" is the best choice, as it will prevent unexpected behavior. -But the whole design of Tempo is based around "config-merging" and a desire to provide a nice API for -applications upon which to build. - -One option is to have Tempo.init() be a function that returns a new Tempo class, which has the -instance-specific definitions baked in. This would allow for name-clashing at the global level, -but prevent it at the instance level. - -Example: -Tempo = Tempo.init({ Tempo.default }) - -Or, we could design things such that if name-clash is detected, it uses the new definition and issues a warning (as well as recording the #parse.result array to assist debugging). The challenge with this is the load that is introduced to check every Event/Period for a possible clash not only in 'name' but in the 'pattern' as well. What if a user defines a Event/Period, and then later defines another one with the same name, but a different pattern? Would the second one override the first one, or would it be ignored? Would the user be notified of the override? Which would be more likely? - -The risk is that this introduces complexity and non-determinism. -That would make debugging more difficult than it needs to be. - -// AI response - -I've reviewed the issues regarding alias naming clashes. Here are my thoughts on how we should handle this to maintain Tempo's flexibility while ensuring predictability: - -### 1. Conflict Philosophy: "Explicit Shadowing" -Tempo's strength is its "config-merging" capability. We should embrace a **"Last One Wins" (Shadowing)** strategy. -- **Reasoning**: This mirrors how CSS or JS modules work. If a user explicitly provides a configuration in `Tempo.init()`, they are stating their intent to use *that* definition for their application context. -- **Hierarchical Priority**: `Local (Instance) > Factory (init) > Plugin > Global Default`. - -### 2. Conflict Detection & Feedback -While "Last One Wins" should be the resolution, the user should be informed if they are accidentally clobbering a built-in alias. -- **Warning Level**: If an alias key (or pattern) matches an existing one, issue a `console.warn` (when `debug: true`). -- **Heuristic Improvement**: Our current `#isAliasCollision` is a good start. We could improve it by checking for "Pattern Saturation"—if a new regex pattern is a subset of an existing one, or vice versa. - -### 3. The "Sandbox" Factory Pattern -I strongly support the idea of `Tempo.init()` returning a **New Tempo Class**. -- **The Pattern**: - ```typescript - const AppTempo = Tempo.init({ period: { 'half-hour': ... } }); - const t = new AppTempo('half-hour'); // Uses AppTempo's registry - ``` -- **Benefits**: This prevents "Global Pollution". Multiple micro-services or components within the same process could have different Tempo configurations without stepping on each other's toes. -- **Implementation**: We could use a `class factory` that extends the base `Tempo` and overrides the static `#global` state for that specific lineage. - -### 4. Traceability & Debugging -The suggestion to record collisions in a `#parse.result` array is excellent. -- **Discovery Trace**: We should include a `meta` property in the result that indicates which registry entry was used (e.g., `source: "local"`, `source: "plugin:astro"`, `source: "default"`). -- **Collision List**: If a clash occurred but was resolved by shadowing, we should keep a list of the "shadowed" candidates in the instance state to assist developers when they wonder why "noon" isn't behaving as expected. - -### 5. Recommendation -- **Default to Overrides**: Don't throw errors for clashes; it breaks the "nice API" goal. -- **Formalize the Factory**: Move towards the `Tempo.init() -> Class` pattern as the primary way to configure Tempo for an application. -- **Enhance Warnings**: Make the collision warnings more descriptive, showing exactly what was overridden and by whom. - -## 2026-04-24: Sandbox Factory Pattern (Consolidated) - -The Sandbox Factory pattern has been finalized to ensure fully isolated, reproducible Tempo configurations without global state pollution. - -### Key Decisions: -1. **API Naming**: We introduced `Tempo.create(options)` as the primary entry point for creating isolated sandboxes. This separates the concern of "forking the library" from `Tempo.init()`, which remains the tool for managing the global singleton or resetting context. -2. **Inheritance vs. Cloning**: Sandboxes now perform a shallow clone of the parent registries (`event`, `period`, etc.) upon initialization. This prevents the "Registry Leak" issue where adding an alias to a sandbox would inadvertently update the parent. -3. **Local Scope Enforcement**: To simplify alias resolution and ensure traceability, sandboxed class states are tagged with `scope: 'local'`. This causes sandboxed aliases to be recorded with a `source: 'local'` tag in parse results, distinguishing them from the base library's global defaults. -4. **Prototype Chain Resolution**: The `ParseEngine` has been hardened to support recursive `ownEntries` lookups. This allows instances to inherit from their sandbox class, which in turn can inherit (selectively) from the base `Tempo` class, providing a robust polymorphic configuration chain. -5. **Secure Discovery**: Each sandbox is registered in `globalThis` via a unique discovery symbol (or string), allowing the `ParseEngine` to correctly resolve custom periods and events even when called from within complex, nested class contexts. - -### Status: -Fully implemented and verified in `sandbox-factory.test.ts`. 100% pass rate achieved. - -## 2026-04-24: Release A Execution Plan (Layout Order Resolver Foundation) - -This section begins the Release A implementation plan from the wishlist roadmap. - -### Release A Goal -Extract layout-order decision logic into a dedicated resolver while preserving behavior. - -### In-Scope (Release A) -- Extract ordering logic currently embedded in Tempo class internals into a standalone helper/module. -- Keep external API behavior unchanged. -- Add deterministic tests for order resolution and swap semantics. -- Add debug-only visibility of final resolved layout order. - -### Out-of-Scope (Release A) -- No `layoutOrder` option yet. -- No input-class pre-filtering yet. -- No planner-based candidate filtering yet. - -### Current Logic Baseline -- Baseline order comes from parse layout object insertion order. -- Locale preference currently swaps named pairs via `mdyLayouts`. -- Existing behavior must remain byte-for-byte equivalent for default configs. - -### Proposed module split - -Target file: `src/engine/engine.layout.ts` - -This follows the existing `engine.*` convention for internal logic extracted from the class engine -(alongside `engine.composer`, `engine.mutate`, `engine.duration`, `engine.term`, `engine.lexer`). -The `discrete/` folder is reserved for standalone importable functionality (`discrete.parse`, -`discrete.format`) — layout-order resolution is internal engine logic, not a public entry point. - -Candidate exported surface: -```ts -export type LayoutEntry = [symbol, string]; - -export interface ResolveLayoutOrderArgs { - layout: Record; - mdyLayouts: [string, string][]; - isMonthDay: boolean; -} - -export function resolveLayoutOrder(args: ResolveLayoutOrderArgs): Record; -``` - -### Work packages - -1. Extract + wire -- Move swap logic from class private method into resolver function. -- Replace class-internal swap implementation with resolver call. -- Keep exact pair-swap semantics unchanged. - -2. Determinism tests -- Add tests that assert: - - no-op when no swap pair matches - - one pair swap in month-day locales - - reverse swap in non-month-day locales - - multiple pair handling remains stable - - unrelated layout relative order is preserved - -3. Debug visibility -- In debug mode, optionally emit resolved order list for diagnostics. -- Keep output behind existing debug gates; no new public API. - -### Test matrix (Release A) - -Core: -- existing parse/order regressions continue passing -- new resolver unit tests pass - -Behavior safety: -- compact 6/8-digit date tests unchanged -- weekday precedence tests unchanged -- relative/offset precedence tests unchanged - -Integration sweep: -- full `vitest` run must be green - -### Acceptance criteria -- All existing behavior preserved with no public API changes. -- Resolver module introduced and used by Tempo class. -- Order determinism covered by focused tests. -- Full test suite passes. - -### Risk controls -- Land extraction first with behavior parity tests before any feature additions. -- Avoid combining this with planner or pre-filtering work in same release. -- Keep commit scope narrow: extraction + tests + minimal wiring. - -### Next step after Release A -- Start Release B by introducing `layoutOrder` option through this resolver. diff --git a/packages/tempo/plan/release-c-prefilter-summary.md b/packages/tempo/plan/release-c-prefilter-summary.md deleted file mode 100644 index 18e8767..0000000 --- a/packages/tempo/plan/release-c-prefilter-summary.md +++ /dev/null @@ -1,113 +0,0 @@ -# Release C: Parse Prefilter Summary (One-Page) - -Date: 2026-04-25 -Branch: `release-c-layout-order-planner` -Scope: Input-class prefiltering behind feature flag (`parsePrefilter`) - -## Current Implementation Status - -- Planner shell extracted and integrated into parse path. -- Prefilter rules implemented and guarded by `parsePrefilter` (default: `false`). -- Numeric-safety constraints protected by targeted tests. -- Debug-only planner telemetry is available when both: - - `parsePrefilter === true` - - `config.debug` enabled - -## Current Benchmarks (selection phase only) - -Source: `npx tsx scratch/bench.parse.prefilter.ts` - -- Candidate reduction: - - Prefilter off: `168 / 168` - - Prefilter on: `113 / 168` - - Reduction: `32.74%` - -- Timing: - - Prefilter off: `165.161 ms` (5000 iterations, 60000 operations) - - Prefilter on: `165.013 ms` - - Delta: `-0.09%` - -## Current Benchmarks (end-to-end constructor + parse path) - -Source: `npx tsx --conditions=development scratch/bench.parse.prefilter.e2e.ts` (expanded real-world corpus) - -- Timing: - - Prefilter off: `112,821 ms` (1000 iterations, 123,000 operations) - - Prefilter on: `111,793 ms` - - Delta: `-0.91%` - - Checksum parity: outputs are consistent - -### Latest Run (April 26, 2026) - -- Prefilter off: `89,788.897 ms` (1000 iterations, 109,000 operations) -- Prefilter on: `78,703.616 ms` -- Delta: `-12.35%` -- Checksum parity: outputs are consistent - -- Rule-hit distribution (`prefilter:on`): - - `isPureNumeric`: 4 - - `hasColon`: 3 - - `isAlphaOnly`: 2 - - `isSixDigits`: 2 - - `hasAgoHence`: 1 - - `isEightDigits`: 1 - -## Interpretation - -- The planner currently removes about one-third of candidate checks. -- The latest selection-phase micro-benchmark is effectively latency-neutral and slightly favorable. -- Trend improved from earlier iterations (`+28.46%` -> `+13.96%` -> `-0.09%`) after optimization and caching. -- The integrated end-to-end benchmark is also favorable (`-0.91%`) on the current representative corpus. -- The expanded-corpus end-to-end benchmark confirms the performance gain is robust (`-0.91%`), not dataset-specific. -- This indicates the architecture is viable for real-world usage, pending further CI and regression validation before any default-on decision. - -## Safety Status - -- Feature flag remains default-off, so current user behavior is unchanged. -- Tests passing for: - - Planner behavior and rule selection - - Flag wiring (global + per-instance) - - Numeric-safety constraints with prefilter enabled - -## Proposed Go/No-Go Thresholds (for broader test-run enablement) - -Use these gates before enabling `parsePrefilter` in wider CI runs: - -1. Correctness gate: -- All current planner, layout, compact-time, numeric-safety, and full regression parse tests must pass with `parsePrefilter: true` in targeted suites. - -2. Candidate reduction gate: -- Maintain at least `25%` average candidate reduction on representative corpus. - -3. Latency gate: -- Selection-phase delta should be `<= +5%` in micro-benchmark before opt-in expansion. -- End-to-end parse latency (integrated benchmark) should be `<= 0%` regression on hot-path corpus before considering default-on. - -4. Observability gate: -- Debug telemetry must remain stable and low-noise (only emits for reductions/fallbacks). - -## Next Work Items - -- Reduce classifier/selection overhead further (target: close gap to <= +5%). -- Verified end-to-end parse latency benchmark (constructor + parse path) confirms favorable performance. -- Expand corpus with high-frequency real-world patterns (ticker-like loops, mixed timezone/event strings). -- Re-check thresholds after optimization pass. - -## Recommendation (Current) - -- Keep `parsePrefilter` as experimental and default-off. -- Keep optimization focused on preserving neutral-or-better latency under larger and real-world corpora. -- Enable in broader CI experiments only after thresholds above are satisfied. - -## CI Integration Plan - -- Add a test matrix job with `parsePrefilter: true` (global and per-instance) for all core and regression suites. -- Monitor for any test failures, output mismatches, or unexpected regressions. -- Capture and review debug/telemetry output for noise or missed reductions. -- If all tests pass and telemetry is clean, consider opt-in enablement for select environments. - -### PR Checklist -- [x] All focused and regression tests pass with `parsePrefilter: true` -- [x] End-to-end and micro-benchmarks show neutral or better performance -- [x] Telemetry is stable and low-noise -- [ ] CI matrix job added and green diff --git a/packages/tempo/src/discrete/discrete.index.ts b/packages/tempo/src/discrete/discrete.index.ts deleted file mode 100644 index 797dbe5..0000000 --- a/packages/tempo/src/discrete/discrete.index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parse } from './discrete.parse.js'; -export { format } from './discrete.format.js'; diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 5b1561f..21b29e8 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -81,19 +81,21 @@ export class AliasEngine { getWords() { return this.#words } constructor(options = {} as AliasEngineOptions) { - this.#parent = options.parent ?? null; + const parent = options.parent; this.#logger = options.logger; this.#config = options.config; this.#id = AliasEngine.#idCounter++; - if (this.#parent instanceof AliasEngine) { - this.#depth = this.#parent.#depth + 1; - this.#state = Object.create(this.#parent.#state); // create a new state object that inherits from the parent engine's state - this.#words = Object.create(this.#parent.#words); // create a new words object that inherits from the parent engine's words for collision detection + if (parent instanceof AliasEngine) { + this.#parent = parent; + this.#depth = parent.#depth + 1; + this.#state = Object.create(parent.#state); // create a new state object that inherits from the parent engine's state + this.#words = Object.create(parent.#words); // create a new words object that inherits from the parent engine's words for collision detection } else { - if (this.#parent) + if (parent) this.#logger?.error(this.#config, "Parent engine must be an instance of AliasEngine"); + this.#parent = null; this.#depth = 0; this.#state = Object.create(null); // initialize an empty state for the root engine (no parent) this.#words = Object.create(null); // initialize an empty words object for the root engine (no parent) diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index 9289d01..4f39216 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -1,5 +1,6 @@ -import { isTempo, logError } from '#tempo/support'; +import { getTemporalIds } from '#library/temporal.library.js'; import { isNumeric, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/assertion.library.js'; +import { isTempo, logError } from '#tempo/support'; import type { TemporalObject, TypeValue } from '#library/type.library.js'; import type { Tempo } from '#tempo/tempo.class.js'; import * as t from '../tempo.type.js'; @@ -20,7 +21,7 @@ export function compose( ): { dateTime: Temporal.ZonedDateTime, timeZone?: string | undefined } { let temporal: TemporalObject | Tempo = today; let timeZone: string | undefined; - let dateTime: Temporal.ZonedDateTime; + let dateTime: Temporal.ZonedDateTime | undefined; switch (type) { case 'Void': @@ -33,8 +34,8 @@ export function compose( case 'String': try { const str = value.replace(/Z$/, ''); - const zdt = Temporal.ZonedDateTime.from(`${str}[${tz}]`); - timeZone = zdt.timeZoneId; + const zdt = Temporal.ZonedDateTime.from(str.includes('[') ? str : `${str}[${tz}]`); + timeZone = getTemporalIds(zdt)[0]; temporal = zdt; onResult?.({ type, value: str, match: 'iso8601' }); } catch (err) { @@ -152,5 +153,5 @@ export function compose( } } - return { dateTime, timeZone }; + return { dateTime: dateTime ?? today, timeZone }; } diff --git a/packages/tempo/src/engine/engine.guard.ts b/packages/tempo/src/engine/engine.guard.ts new file mode 100644 index 0000000..33a02b9 --- /dev/null +++ b/packages/tempo/src/engine/engine.guard.ts @@ -0,0 +1,86 @@ +import { isString, isSymbol } from '#library/assertion.library.js'; +import { Match } from '#tempo/support/support.default.js'; + +/** + * Interface for the Master Guard scanner. + */ +export interface MasterGuard { + test(input: string): boolean; +} + +/** + * Create a new Master Guard scanner based on a list of allowed tokens. + * + * @param words - List of strings or symbols that are valid in the current context. + * @returns An object with a .test() method that performs a greedy scan-and-consume validation. + */ +export function createMasterGuard(words: (string | symbol)[]): MasterGuard { + const wordsList = words + .filter(w => isString(w) || (isSymbol(w) && !!w.description)) + .map(w => (isSymbol(w) ? w.description! : (w as string)).toLowerCase()) + .filter(Boolean); + + const allowedTokens = new Set(wordsList); + + let maxT = 0; + for (const w of wordsList) if (w.length > maxT) maxT = w.length; + const maxTokenLength = maxT; + + return { + test(input: string): boolean { + if (!input || !isString(input)) return false; + + let i = 0; + const len = input.length; + let matchedAny = false; + + while (i < len) { + const char = input[i]; + + // 1. Skip spaces + if (char === ' ' || char === '\n' || char === '\t' || char === '\r') { + i++; + continue; + } + + // 2. Try Bracket match (starts with [) + if (char === '[') { + const sub = input.substring(i); + const match = sub.match(Match.bracket); + if (match && match.index === 0) { + i += match[0].length; + matchedAny = true; + continue; + } + } + + // 3. Try Longest Token match from Set + let matched = false; + const searchLen = Math.min(maxTokenLength, len - i); + const slice = input.substring(i, i + searchLen).toLowerCase(); + + for (let l = searchLen; l > 0; l--) { + const candidate = slice.substring(0, l); + if (allowedTokens.has(candidate)) { + i += l; + matched = true; + matchedAny = true; + break; + } + } + if (matched) continue; + + // 4. Try Fallback char (Match.guard) + if (Match.guard.test(char)) { + i++; + matchedAny = true; + continue; + } + + return false; // No valid match at current position + } + + return matchedAny; + } + }; +} diff --git a/packages/tempo/src/engine/engine.normalizer.ts b/packages/tempo/src/engine/engine.normalizer.ts new file mode 100644 index 0000000..00cb37b --- /dev/null +++ b/packages/tempo/src/engine/engine.normalizer.ts @@ -0,0 +1,221 @@ +import { isDefined, isEmpty, isZonedDateTime, isNumeric } from '#library/assertion.library.js'; +import type { TypeValue } from '#library/type.library.js'; +import { ownKeys } from '#library/primitive.library.js'; +import { getRuntime, sym, Match } from '#tempo/support'; +import { getTemporalIds, instant } from '#library/temporal.library.js'; +import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './engine.lexer.js'; +import { resolveTermMutation } from './engine.term.js'; +import enums from '#tempo/support/support.enum.js'; +import * as t from '../tempo.type.js'; + +/** + * Maximum depth for recursive alias resolution. + * This ceiling (50) is generous to accommodate complex alias chains while remaining well above + * the PatternCompiler.matcher depth limit (~10), preventing stack overflows during normalization. + */ +const MAX_TEMPO_RESOLVE_DEPTH = 50; + +/** + * Context provided to the normalizer to handle recursion and state management. + */ +export interface NormalizerContext { + state: t.Internal.State; + isAnchored: boolean; + resolvingKeys: Set; + subParse: (value: string, dateTime: Temporal.ZonedDateTime, resolvingKeys: Set) => TypeValue; + conform: (value: any, dateTime: Temporal.ZonedDateTime, resolvingKeys: Set) => TypeValue; +} + +/** + * Provide a lightweight host context that mimics a Tempo instance for functional alias handlers. + */ +export function getResolutionContext(ctx: NormalizerContext, dateTime: Temporal.ZonedDateTime) { + const { state, resolvingKeys, conform } = ctx; + const TempoClass = getRuntime().modules['Tempo']; + return { + add: (val: any) => dateTime.add(val), + subtract: (val: any) => dateTime.subtract(val), + with: (val: any) => dateTime.with(val), + set: (val: any, opt?: any) => { + const res = conform(val, dateTime, resolvingKeys); + return (TempoClass as any)?.from(isZonedDateTime(res.value) ? res.value : dateTime, { ...state.config, ...opt }); + }, + toNow: () => { + const [tz, cal] = getTemporalIds(state.config.timeZone, state.config.calendar); + return instant().toZonedDateTimeISO(tz).withCalendar(cal); + }, + get tz() { return getTemporalIds(state.config.timeZone)[0] }, + get cal() { return getTemporalIds(state.config.timeZone, state.config.calendar)[1] }, + toDateTime: () => dateTime, + get hh() { return dateTime.hour }, + get mi() { return dateTime.minute }, + get ss() { return dateTime.second }, + get yy() { return dateTime.year }, + get mm() { return dateTime.month }, + get dd() { return dateTime.day }, + [sym.$Identity]: true, + config: state.config + }; +} + +/** + * Normalize a set of regex groups into a Temporal.ZonedDateTime. + */ +export function normalizeMatch( + groups: t.Groups, + dateTime: Temporal.ZonedDateTime, + ctx: NormalizerContext +): Temporal.ZonedDateTime { + const { state, isAnchored } = ctx; + + // 1. Zone + dateTime = parseZone(groups, dateTime, state.config); + + // 2. Aliases & Groups + dateTime = resolveAliases(groups, dateTime, ctx); + if (state.errored) return dateTime; + + // 3. Weekday, Date, Time + dateTime = parseWeekday(groups, dateTime, state.config); + dateTime = parseDate(groups, dateTime, state.config, state.parse["pivot"]); + dateTime = parseTime(groups, dateTime); + + return dateTime; +} + +/** + * Resolve {event} | {period} aliases found in the matched groups. + */ +export function resolveAliases( + groups: t.Groups, + dateTime: Temporal.ZonedDateTime, + ctx: NormalizerContext +): Temporal.ZonedDateTime { + const { state, resolvingKeys, subParse } = ctx; + const prevAnchor = state.anchor; + const prevZdt = state.zdt; + + state.anchor = dateTime; + state.zdt = dateTime; + + state.parseDepth = (state.parseDepth ?? 0) + 1; + const isRoot = state.parseDepth === 1; + if (isRoot) state.matches = []; + + const TempoClass = getRuntime().modules['Tempo']; + const aliasEngine = state.aliasEngine ?? (TempoClass as any)?.[sym.$Internal]?.().aliasEngine; + + try { + for (const key of ownKeys(groups)) { + if (key === 'slk') { + const slk = groups[key]; + const result = resolveTermMutation(TempoClass, state as any, 'set', slk, undefined, dateTime); + + if (result === null) { + state.errored = true; + delete groups[key]; + break; + } + + dateTime = result; + delete groups[key]; + continue; + } + + if (Match.named.test(key)) { + delete groups[key]; + continue; + } + + const register = aliasEngine?.getAlias(key); + if (!register) continue; + + const aliasKey = register.name; + if (resolvingKeys.size > MAX_TEMPO_RESOLVE_DEPTH || resolvingKeys.has(aliasKey)) { + const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; + state.errored = true; + if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new RangeError(msg)); + delete groups[key]; + continue; + } + + resolvingKeys.add(aliasKey); + + try { + const host = getResolutionContext(ctx, dateTime); + const res = aliasEngine?.resolveAlias(key as any, host); + if (!res) continue; + + try { + const mapped = ({ + evt: { type: 'Event', pat: 'dt' }, + per: { type: 'Period', pat: 'tm' } + } as const)[res.type as 'evt' | 'per']; + + if (!mapped) + throw new Error(`[ParseEngine] Unexpected AliasType: ${res.type}`); + + const { type, pat } = mapped; + + accumulateResult(state, { type, value: res.key as any, match: pat, source: res.source, groups: { [key]: res.value } }); + + if (!isEmpty(res.value) && res.value !== String(groups[key])) { + const resolving = new Set(resolvingKeys); + resolving.add(res.key); + + const subAnchor: any = state.anchor; + state.anchor = dateTime; + const resMatch = subParse(res.value, dateTime, resolving); + state.anchor = subAnchor; + + if (resMatch.type === 'Temporal.ZonedDateTime') + dateTime = resMatch.value; + } + } finally { + state.zdt = dateTime; + delete groups[key]; + } + } finally { + resolvingKeys.delete(aliasKey); + } + } + } finally { + if (isDefined(prevAnchor)) state.anchor = prevAnchor; + else delete state.anchor; + if (isDefined(prevZdt)) state.zdt = prevZdt; + else delete state.zdt; + state.parseDepth--; + if (state.parseDepth === 0) delete state.matches; + } + + if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) { + const mm = prefix(groups["mm"] as t.MONTH); + if (TempoClass) groups["mm"] = (TempoClass as any).MONTH[mm as t.MONTH]!.toString().padStart(2, '0'); + else if (enums.MONTH[mm as t.MONTH]) groups["mm"] = enums.MONTH[mm as t.MONTH]!.toString().padStart(2, '0'); + } + + return dateTime; +} + +/** + * Accumulate match results for diagnostic tracing. + */ +export function accumulateResult(state: t.Internal.State, ...rest: Partial[]) { + const match = Object.assign({}, ...rest) as t.Internal.Match; + + if (isDefined(state.parse.anchor)) + match.anchor = state.parse.anchor; + + if (!isDefined(match.isAnchored) && isDefined(state.parse.isAnchored)) + match.isAnchored = state.parse.isAnchored; + + const res = state.parse.result; + if (isDefined(res) && !Object.isFrozen(res)) { + const isDuplicate = res.some(existing => + existing.match === match.match && + existing.source === match.source && + String(existing.anchor ?? '') === String(match.anchor ?? '') + ); + if (!isDuplicate) res.push(match); + } +} diff --git a/packages/tempo/src/engine/engine.pattern.ts b/packages/tempo/src/engine/engine.pattern.ts index aca9bb0..9c0105d 100644 --- a/packages/tempo/src/engine/engine.pattern.ts +++ b/packages/tempo/src/engine/engine.pattern.ts @@ -25,6 +25,8 @@ export class PatternCompiler { this.#state = options.state; } + get state() { return this.#state; } + /** * Translates {layout} into an anchored, case-insensitive RegExp. * Includes recursive expansion of placeholders using snippet registries. @@ -75,7 +77,8 @@ export class PatternCompiler { } if (res && name.includes('.')) { // wrap dotted extensions for identification - const safeName = name.replace(/\./g, '_'); + let safeName = name.trim().replace(/[^A-Za-z0-9_$]/g, '_'); + if (!/^[A-Za-z_$]/.test(safeName)) safeName = `_${safeName}`; if (!res.startsWith(`(?<${safeName}>`)) res = `(?<${safeName}>${res})`; } diff --git a/packages/tempo/src/engine/engine.term.ts b/packages/tempo/src/engine/engine.term.ts index cce7c84..2187ae6 100644 --- a/packages/tempo/src/engine/engine.term.ts +++ b/packages/tempo/src/engine/engine.term.ts @@ -1,14 +1,14 @@ -import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; +import { toZonedDateTime, toInstant, getTemporalIds } from '#library/temporal.library.js'; import { isDefined, isString, isZonedDateTime, isNumeric } from '#library/assertion.library.js'; import { asArray } from '#library/coercion.library.js'; import { TermError, getLargestUnit, SCHEMA, Match, isTempo } from '#tempo/support'; -import { getRange, getTermRange, resolveTermShift, findTermPlugin } from '../plugin/term.util.js'; +import { getRange, getTermRange, resolveTermShift, findTermPlugin } from '../plugin/term/term.util.js'; import { getHost } from '../plugin/plugin.util.js'; import { parseModifier } from './engine.lexer.js'; import type { Tempo } from '../tempo.class.js'; -import type { TempoType } from '../plugin/plugin.type.js'; +import type { TempoTermType } from '../plugin/term/term.type.js'; /** * Internal helper to safely get the ZonedDateTime from a Tempo instance or raw object @@ -26,7 +26,7 @@ const toZdt = (v: any): Temporal.ZonedDateTime => isTempo(v) ? v.toDateTime() : * @param zdt - The current ZonedDateTime state * @returns The mutated ZonedDateTime */ -export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: string, unit: string, offset: any, zdt: Temporal.ZonedDateTime): Temporal.ZonedDateTime | null { +export function resolveTermMutation(Tempo: TempoTermType, instance: Tempo, mutate: string, unit: string, offset: any, zdt: Temporal.ZonedDateTime): Temporal.ZonedDateTime | null { if (!isZonedDateTime(zdt)) return zdt; const [termPart, rangePart] = unit.startsWith('#') @@ -40,8 +40,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s return null; } - const tz = zdt.timeZoneId; - const cal = zdt.calendarId; + const [tz, cal] = getTemporalIds(zdt); // Slick Shorthand Parsing (e.g. #qtr.>2, #zodiac.<) let mod: string | undefined; @@ -514,7 +513,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s /** * Resolves a term identifier (e.g. '#quarter') to its current value (start of cycle). */ -export function resolveTermValue(Tempo: TempoType, instance: Tempo, term: string, zdt: Temporal.ZonedDateTime): Temporal.ZonedDateTime | null { +export function resolveTermValue(Tempo: TempoTermType, instance: Tempo, term: string, zdt: Temporal.ZonedDateTime): Temporal.ZonedDateTime | null { return resolveTermMutation(Tempo, instance, 'start', term, term, zdt); } diff --git a/packages/tempo/src/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts index bf2456f..ec7c4fb 100644 --- a/packages/tempo/src/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -1,13 +1,13 @@ -import { isString, isObject, isDefined, isUndefined, isZonedDateTime } from '#library/assertion.library.js'; +import { getTemporalIds } from '#library/temporal.library.js'; +import { isString, isObject, isDefined, isUndefined } from '#library/assertion.library.js'; import { singular } from '#library/string.library.js'; import { getAccessors } from '#library/reflection.library.js'; import { ifDefined } from '#library/object.library.js'; import { getRelativeTime } from '#library/international.library.js'; -import { defineInterpreterModule, interpret } from '../plugin/plugin.util.js'; +import { defineInterpreterModule, interpret, type TempoModule } from '../plugin/plugin.util.js'; import { enums, isTempo } from '#tempo/support'; -import type { Module } from '../plugin/plugin.type.js'; -import type { Tempo } from '../tempo.class.js'; +import { Tempo } from '../tempo.class.js'; declare module '../tempo.class.js' { namespace Tempo { @@ -94,8 +94,11 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) const offset = new (this.constructor as any)(value, { ...opts, anchor: this, mode: enums.MODE.Strict }); const offsetZdt = offset.toDateTime(); - const diffZone = selfZdt.timeZoneId !== offsetZdt.timeZoneId; - const dur = selfZdt.until(offsetZdt.withCalendar(selfZdt.calendarId), { largestUnit: diffZone ? 'hours' : (unit ?? 'years') }); + const [selfTz, selfCal] = getTemporalIds(selfZdt); + const [offsetTz] = getTemporalIds(offsetZdt); + + const diffZone = selfTz !== offsetTz; + const dur = selfZdt.until(offsetZdt.withCalendar(selfCal), { largestUnit: diffZone ? 'hours' : (unit ?? 'years') }); if (isDefined(unit)) unit = `${singular(unit)}s`; @@ -117,8 +120,8 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) const locale = (this as any).config['locale']; const rtConfig = (this as any).config.intl?.relativeTime; const rtOptions = opts['relativeTime']; - - const rtf = (typeof rtOptions === 'function' ? rtOptions : rtOptions?.format) + + const rtf = (typeof rtOptions === 'function' ? rtOptions : rtOptions?.format) || (typeof rtConfig === 'function' ? rtConfig : rtConfig?.format) || opts['rtfFormat'] || (this as any).config['rtfFormat']; @@ -154,7 +157,7 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) * string -> EDO * DurationLikeObject -> EDO (with iso string) */ -duration.toDuration = (input: string | Temporal.DurationLikeObject) => { +(duration as any).toDuration = (input: string | Temporal.DurationLikeObject) => { const dur = Temporal.Duration.from(input); return toDuration(dur); } @@ -162,7 +165,7 @@ duration.toDuration = (input: string | Temporal.DurationLikeObject) => { /** * Functional Module to attach duration methods to Tempo. */ -export const DurationModule: Module = defineInterpreterModule('DurationModule', duration, { +export const DurationModule: TempoModule = defineInterpreterModule('DurationModule', duration, { duration(this: typeof Tempo, input: any) { return interpret(this, 'DurationModule', 'toDuration', false, input); } diff --git a/packages/tempo/src/discrete/discrete.format.ts b/packages/tempo/src/module/module.format.ts similarity index 100% rename from packages/tempo/src/discrete/discrete.format.ts rename to packages/tempo/src/module/module.format.ts diff --git a/packages/tempo/src/module/module.index.ts b/packages/tempo/src/module/module.index.ts new file mode 100644 index 0000000..02f3511 --- /dev/null +++ b/packages/tempo/src/module/module.index.ts @@ -0,0 +1,2 @@ +export { parse } from './module.parse.js'; +export { format } from './module.format.js'; diff --git a/packages/tempo/src/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts index c3ec211..414517e 100644 --- a/packages/tempo/src/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -2,8 +2,8 @@ import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#li import { singular } from '#library/string.library.js'; import { sym, enums } from '#tempo/support'; -import { defineInterpreterModule } from '../plugin/plugin.util.js'; -import { findTermPlugin } from '../plugin/term.util.js'; +import { defineInterpreterModule, type TempoModule } from '../plugin/plugin.util.js'; +import { findTermPlugin } from '../plugin/term/term.util.js'; import { resolveTermMutation } from '../engine/engine.term.js'; import type { Tempo } from '../tempo.class.js'; import type * as t from '../tempo.type.js'; @@ -19,6 +19,7 @@ declare module '#library/type.library.js' { */ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options = {}) { const state = (this as any)[sym.$Internal](); + if (isUndefined(state.mutateDepth)) state.mutateDepth = 0; if (!isZonedDateTime(state.zdt)) return this; const { zdt: selfZdt } = state; const overrides = { @@ -36,7 +37,6 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options // Shift the current instance to the target timezone first let zdt = selfZdt.withTimeZone(overrides.timeZone).withCalendar(overrides.calendar); state.parseDepth++; - const isRoot = state.parseDepth === 1; const matches = Array.isArray(this.parse?.result) ? Array.from(this.parse.result) : []; try { @@ -119,7 +119,7 @@ function mutate(this: Tempo, type: 'add' | 'set', args?: any, options: t.Options case 'add.year': case 'add.month': case 'add.week': case 'add.day': case 'add.hour': case 'add.minute': case 'add.second': case 'add.millisecond': case 'add.microsecond': case 'add.nanosecond': - return currZdt.add({ [singular(single) + 's']: offset }); + return currZdt.add({ [`${single}s`]: offset }); case 'set.period': case 'set.time': case 'set.date': case 'set.event': case 'set.dow': case 'set.wkd': { @@ -210,4 +210,4 @@ const MutateEngine = { /** * MutateModule registration */ -export const MutateModule = defineInterpreterModule('MutateModule', MutateEngine); +export const MutateModule: TempoModule = defineInterpreterModule('MutateModule', MutateEngine); diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/module/module.parse.ts similarity index 67% rename from packages/tempo/src/discrete/discrete.parse.ts rename to packages/tempo/src/module/module.parse.ts index 7f8ef9f..996ad17 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -6,48 +6,21 @@ import { isNumeric } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import type { TypeValue } from '#library/type.library.js'; -import { resolveTermMutation, resolveTermValue } from '../engine/engine.term.js'; + +import { resolveTermValue } from '../engine/engine.term.js'; import { selectLayoutPatterns } from '../engine/engine.planner.js'; -import { prefix, parseWeekday, parseDate, parseTime, parseZone } from '../engine/engine.lexer.js'; import { compose } from '../engine/engine.composer.js'; +import { normalizeMatch, accumulateResult } from '../engine/engine.normalizer.js'; -import { getRange, getTermRange } from '../plugin/term.util.js'; +import { getRange, getTermRange } from '../plugin/term/term.util.js'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; -import type { Range, ResolvedRange } from '../plugin/plugin.type.js'; +import type { Range, ResolvedRange } from '../plugin/term/term.type.js'; import { sym, isTempo, TermError, getRuntime, Match } from '../support/support.index.js'; import { markConfig, setPatterns, init, extendState } from '../support/support.index.js'; import { setProperty } from '#tempo/support/support.util.js'; -import enums from '../support/support.enum.js'; import * as t from '../tempo.type.js'; import type { Tempo } from '../tempo.class.js'; -/** - * Provide a lightweight host context that mimics a Tempo instance for functional alias handlers. - * @internal - */ -function getResolutionContext(state: any, dateTime: Temporal.ZonedDateTime, resolvingKeys: Set) { - const TempoClass = getRuntime().modules['Tempo']; - return { - add: (val: any) => dateTime.add(val), - subtract: (val: any) => dateTime.subtract(val), - with: (val: any) => dateTime.with(val), - set: (val: any, opt?: any) => { - const res = _ParseEngine.conform(state, val, dateTime, true, resolvingKeys); - return (TempoClass as any)?.from(isZonedDateTime(res.value) ? res.value : dateTime, { ...state.config, ...opt }); - }, - toNow: () => instant().toZonedDateTimeISO(state.config.timeZone).withCalendar(state.config.calendar), - toDateTime: () => dateTime, - get hh() { return dateTime.hour }, - get mi() { return dateTime.minute }, - get ss() { return dateTime.second }, - get yy() { return dateTime.year }, - get mm() { return dateTime.month }, - get dd() { return dateTime.day }, - [sym.$Identity]: true, - config: state.config - }; -} - /** * Internal Parse Engine Implementation */ @@ -78,19 +51,19 @@ const _ParseEngine = { try { const { config } = state; + const TempoClass = getRuntime().modules['Tempo']; + const terms = getRuntime().pluginsDb.terms; + const val = dateTime ?? state.anchor ?? state.config.anchor ?? (isTempo(tempo) ? (tempo as any).toDateTime() : (isZonedDateTime(tempo) ? tempo : (isInstant(tempo) ? tempo.toZonedDateTimeISO(config.timeZone) : undefined))); - const basis = isTempo(val) ? (val as any).toDateTime() : (isDefined(val) ? val : instant().toZonedDateTimeISO(config.timeZone)); + const [tz, cal] = getTemporalIds(config.timeZone, config.calendar); + const basis = isTempo(val) ? (val as any).toDateTime() : (isDefined(val) ? val : instant().toZonedDateTimeISO(tz).withCalendar(cal)); const isAnchored = isDefined(val); if (isRoot) { state.parse.anchor = basis; state.parse.isAnchored = isAnchored; } - const [tz, cal] = isTempo(basis) ? [(basis as any).tz, (basis as any).cal] : getTemporalIds(basis ?? config.timeZone, basis ?? config.calendar); - today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : (isZonedDateTime(val) ? val : instant().toZonedDateTimeISO(tz).withCalendar(cal))); - - const TempoClass = getRuntime().modules['Tempo']; - const terms = getRuntime().pluginsDb.terms; + today = basis; if (term) { const ident = term.startsWith('#') ? term.slice(1) : term; @@ -158,7 +131,7 @@ const _ParseEngine = { const { timeZone: tz2, calendar: cal2 } = state.config; const [targetTz, targetCal] = getTemporalIds(tz2, cal2); - const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal, (m) => _ParseEngine.result(state, m), state.config.timeStamp, state.config); + const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal, (m) => accumulateResult(state, m), state.config.timeStamp, state.config); dateTime = dt; if (timeZone && state) state.config.timeZone = timeZone; @@ -197,14 +170,11 @@ const _ParseEngine = { return undefined as any; } + if (timeZone) zdt = zdt.withTimeZone(timeZone); + if (calendar) zdt = zdt.withCalendar(calendar); if (!isEmpty(options)) zdt = zdt.with(options as Temporal.ZonedDateTimeLikeObject); - if (timeZone) - if (isZonedDateTime(zdt)) zdt = zdt.withTimeZone(timeZone); - if (calendar) - zdt = zdt.withCalendar(calendar); - - _ParseEngine.result(state, { type: 'Temporal.ZonedDateTimeLike', value: zdt, match: 'Temporal.ZonedDateTimeLike' }); + accumulateResult(state, { type: 'Temporal.ZonedDateTimeLike', value: zdt, match: 'Temporal.ZonedDateTimeLike' }); return Object.assign(arg, { type: 'Temporal.ZonedDateTime', @@ -214,8 +184,9 @@ const _ParseEngine = { if (isTempo(value)) { const res = (value as any).toDateTime(); - state.config.timeZone = res.timeZoneId; - state.config.calendar = res.calendarId; + const [tz, cal] = getTemporalIds(res); + state.config.timeZone = tz; + state.config.calendar = cal; return Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: res }); } @@ -273,11 +244,11 @@ const _ParseEngine = { if (type === 'String') { if (isEmpty(trim)) { - _ParseEngine.result(state, { type: 'Empty', value: trim, match: 'Empty' }); + accumulateResult(state, { type: 'Empty', value: trim, match: 'Empty' }); return Object.assign(arg, { type: 'Empty' }); } if (isIntegerLike(trim)) { - _ParseEngine.result(state, { type: 'BigInt', value: asInteger(trim), match: 'BigInt' }); + accumulateResult(state, { type: 'BigInt', value: asInteger(trim), match: 'BigInt' }); return Object.assign(arg, { type: 'BigInt', value: asInteger(trim) }); } } @@ -320,15 +291,15 @@ const _ParseEngine = { const hasTime = Object.keys(groups) .some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key) || (Match.named.test(key) && key.endsWith('tm'))) || Object.values(groups).includes('now'); - _ParseEngine.result(state, { match: symKey.description, value: trim, groups: { ...groups } }); - - dateTime = parseZone(groups, dateTime, state.config); - dateTime = _ParseEngine.parseGroups(state, groups, dateTime, isAnchored, resolvingKeys); - if (state.errored) return arg; + accumulateResult(state, { match: symKey.description, value: trim, groups: { ...groups } }); - dateTime = parseWeekday(groups, dateTime, state.config); - dateTime = parseDate(groups, dateTime, state.config, state.parse["pivot"]); - dateTime = parseTime(groups, dateTime); + dateTime = normalizeMatch(groups, dateTime, { + state, + isAnchored, + resolvingKeys, + subParse: (v, dt, rk) => _ParseEngine.parseLayout(state, v, dt, true, rk), + conform: (v, dt, rk) => _ParseEngine.conform(state, v, dt, true, rk) + }); const isChanged = !dateTime.toPlainTime().equals(anchorTime); if (!isAnchored && !hasTime && !isChanged) @@ -353,110 +324,6 @@ const _ParseEngine = { return groups as t.Groups; }, - /** resolve {event} | {period} to their date | time values (mutates groups) */ - parseGroups(state: t.Internal.State, groups: t.Groups, dateTime: Temporal.ZonedDateTime, isAnchored: boolean, resolvingKeys: Set): Temporal.ZonedDateTime { - const prevAnchor = state.anchor; - const prevZdt = state.zdt; - - state.anchor = dateTime; - state.zdt = dateTime; - - state.parseDepth = (state.parseDepth ?? 0) + 1; - const isRoot = state.parseDepth === 1; - if (isRoot) state.matches = []; - - const TempoClass = getRuntime().modules['Tempo']; - const aliasEngine = state.aliasEngine ?? (TempoClass as any)?.[sym.$Internal]?.().aliasEngine; - - try { - for (const key of ownKeys(groups)) { - if (key === 'slk') { - const slk = groups[key]; - const result = resolveTermMutation(TempoClass, state as any, 'set', slk, undefined, dateTime); - - if (result === null) { - state.errored = true; - delete groups[key]; - break; - } - - dateTime = result; - delete groups[key]; - continue; - } - - if (Match.named.test(key)) { // remove structural markers - delete groups[key]; - continue; - } - - const register = aliasEngine?.getAlias(key); - if (!register) continue; - - const aliasKey = register.name; - if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) { - const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; - state.errored = true; - if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new RangeError(msg)); - delete groups[key]; - continue; - } - - resolvingKeys.add(aliasKey); - - const host = getResolutionContext(state, dateTime, resolvingKeys); - const res = aliasEngine?.resolveAlias(key as any, host); - if (!res) continue; - - try { - const mapped = ({ - evt: { type: 'Event', pat: 'dt' }, - per: { type: 'Period', pat: 'tm' } - } as const)[res.type as 'evt' | 'per']; - - if (!mapped) - throw new Error(`[ParseEngine] Unexpected AliasType: ${res.type}`); - - const { type, pat } = mapped; - - _ParseEngine.result(state, { type, value: res.key as any, match: pat, source: res.source, groups: { [key]: res.value } }); - - // If it resolved to a new string, we re-parse it - if (!isEmpty(res.value) && res.value !== String(groups[key])) { - const resolving = new Set(resolvingKeys); - resolving.add(res.key); - // Explicitly propagate anchor for recursive parse - const prevAnchor: any = state.anchor; - state.anchor = dateTime; - const resMatch = _ParseEngine.parseLayout(state, res.value, dateTime, true, resolving); - state.anchor = prevAnchor; - - if (resMatch.type === 'Temporal.ZonedDateTime') - dateTime = resMatch.value; - } - } finally { - state.zdt = dateTime; - delete groups[key]; - } - } - } finally { - if (isDefined(prevAnchor)) state.anchor = prevAnchor; - else delete state.anchor; - if (isDefined(prevZdt)) state.zdt = prevZdt; - else delete state.zdt; - state.parseDepth--; - if (state.parseDepth === 0) delete state.matches; - } - - if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) { - const mm = prefix(groups["mm"] as t.MONTH); - if (TempoClass) groups["mm"] = (TempoClass as any).MONTH[mm as t.MONTH]!.toString().padStart(2, '0'); - else if (enums.MONTH[mm as t.MONTH]) groups["mm"] = enums.MONTH[mm as t.MONTH]!.toString().padStart(2, '0'); - } - - return dateTime; - }, - /** check if we've been given a ZonedDateTimeLike object */ isZonedDateTimeLike(state: any, tempo: t.DateTime | t.Options | undefined): tempo is Temporal.ZonedDateTimeLike & { value?: any } { if (!isObject(tempo) || isEmpty(tempo) || (tempo.constructor !== Object && tempo.constructor !== undefined)) @@ -470,21 +337,6 @@ const _ParseEngine = { .filter(isString) .some((key: string) => state.ZONED_DATE_TIME.has(key) && !state.CONFIG.has(key)) }, - - /** accumulate match results */ - result(state: any, ...rest: Partial[]) { - const match = Object.assign({}, ...rest) as t.Internal.Match; - - if (isDefined(state.parse.anchor)) - match.anchor = state.parse.anchor; - - if (!isDefined(match.isAnchored) && isDefined(state.parse.isAnchored)) - match.isAnchored = state.parse.isAnchored; - - const res = state.parse.result; - if (isDefined(res) && !Object.isFrozen(res)) - if (!res.includes(match)) res.push(match); - } } const withState = (fn: (state: t.Internal.State, ...args: A) => R) => { @@ -507,9 +359,8 @@ export const ParseEngine = { conform: withState(_ParseEngine.conform), parseLayout: withState(_ParseEngine.parseLayout), parseMatch: withState(_ParseEngine.parseMatch), - parseGroups: withState(_ParseEngine.parseGroups), isZonedDateTimeLike: withState(_ParseEngine.isZonedDateTimeLike), - result: withState(_ParseEngine.result) + result: withState(accumulateResult) }; /** diff --git a/packages/tempo/src/plugin/extend/extend.ticker.ts b/packages/tempo/src/plugin/extend/extend.ticker.ts index be49067..30470ad 100644 --- a/packages/tempo/src/plugin/extend/extend.ticker.ts +++ b/packages/tempo/src/plugin/extend/extend.ticker.ts @@ -1,12 +1,11 @@ -import { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumber, isNumeric, isFiniteNumber } from '#library/assertion.library.js'; +import { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumeric, isFiniteNumber } from '#library/assertion.library.js'; import { Pledge } from '#library/pledge.class.js'; import { asArray } from '#library/coercion.library.js'; import { instant, normaliseFractionalDurations } from '#library/temporal.library.js'; import { sym, markConfig, enums } from '#tempo/support'; -import { defineExtension, attachStatics } from '../plugin.util.js' -import type { Tempo } from '../../tempo.class.js' -import type { Extension, TempoType } from '../plugin.type.js' +import { defineExtension, attachStatics, type TempoExtension, type TempoType } from '../plugin.util.js'; +import { Tempo } from '../../tempo.class.js'; declare module '../../tempo.class.js' { namespace Tempo { @@ -351,9 +350,9 @@ class TickerInstance implements Ticker.Descriptor { /** * # TickerModule */ -export const TickerModule: Extension = defineExtension({ +export const TickerModule: TempoExtension = defineExtension({ name: 'TickerModule', - install(this: Tempo, TempoClass: TempoType) { + install(this: TempoType, TempoClass: TempoType) { attachStatics(TempoClass, { ticker: function (this: TempoType, arg1: any, arg2?: any): Ticker.Instance { const instance = new TickerInstance(this as unknown as TempoType, arg1, arg2); diff --git a/packages/tempo/src/plugin/plugin.index.ts b/packages/tempo/src/plugin/plugin.index.ts index 59b5198..f383d37 100644 --- a/packages/tempo/src/plugin/plugin.index.ts +++ b/packages/tempo/src/plugin/plugin.index.ts @@ -8,3 +8,4 @@ export * from './plugin.util.js'; export * from './plugin.type.js'; +export * from './term/term.type.js'; diff --git a/packages/tempo/src/plugin/plugin.type.ts b/packages/tempo/src/plugin/plugin.type.ts index 094ac76..aa1c3cc 100644 --- a/packages/tempo/src/plugin/plugin.type.ts +++ b/packages/tempo/src/plugin/plugin.type.ts @@ -1,42 +1,17 @@ -import type { Prettify, Property } from '#library/type.library.js'; -import type { Tempo } from '../tempo.class.js'; -import { TermError } from '#tempo/support'; - -export type TempoType = typeof Tempo & { - [TermError]?: (config: any, term: string) => void; -} - -/** - * ## TermPlugin - * Interface for term-driven parsing and resolution. - */ -export interface TermPlugin { - key: string; - scope?: string; - description?: string; - groups?: any; - ranges?: any[]; - resolve?: (this: Tempo, anchor?: any) => Range[]; - define: (this: Tempo, keyOnly?: boolean, anchor?: any) => string | Range | Range[] | undefined; -} - -/** mapping of terms to their resolved values */ -export type Terms = Property; - /** * ## Plugin * Interface for general Tempo plugins (Modules/Extensions). */ -export interface Plugin { +export interface Plugin { name: string; - install: (this: Tempo, t: TempoType) => void; + install: (this: T, t: T) => void; } /** * ## Module * Type for Module plugins. */ -export interface Module extends Plugin { +export interface Module extends Plugin { [key: string]: any; } @@ -44,55 +19,6 @@ export interface Module extends Plugin { * ## Extension * Type for Extension plugins. */ -export interface Extension extends Plugin { +export interface Extension extends Plugin { [key: string]: any; -} - - -/** - * ## Range - * Discrete time interval within a specific term. - * - * When Range.year is a number it is interpreted as a relative offset if |year| ≤ 10 - * and as an absolute year otherwise. - */ -export type Range = Prettify<{ - key: string; - group?: string; // categorization marker (e.g. 'western', 'chinese', 'fiscal') - [meta: string]: any; -} & ( - { year: number } | { month: number } | { week: number } | { day: number } | - { hour: number } | { minute: number } | { second: number } | - { millisecond: number } | { microsecond: number } | { nanosecond: number } - ) & { - year?: number; - month?: number; - week?: number; - day?: number; - hour?: number; - minute?: number; - second?: number; - millisecond?: number; - microsecond?: number; - nanosecond?: number; - }>; - - -/** - * ## ResolvedRange - * Range with additional metadata. - */ -// export interface ResolvedRange extends Range { -// label: string; -// active: boolean; -// index: number; -// } -export type ResolvedRange = Range & { - start: Tempo; - end: Tempo; - scope?: string; - label?: string; - unit?: string; - rollover?: string; - [str: string]: any; } \ No newline at end of file diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index d974a06..91d1fc8 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -4,7 +4,12 @@ import { secureRef } from '#library/proxy.library.js'; import { sym, getRuntime, isTempo } from '#tempo/support'; import { hasOwn } from '#tempo/support/support.util.js'; import type { Tempo } from '../tempo.class.js'; -import type { Plugin } from './plugin.type.js'; +import type { Plugin, Module, Extension } from './plugin.type.js'; + +export type TempoType = typeof Tempo; +export type TempoPlugin = Plugin; +export type TempoModule = Module; +export type TempoExtension = Extension; export function getHost(t: any): any { const TempoClass = getRuntime().modules['Tempo']; @@ -83,7 +88,7 @@ export function interpret(t: any, module: string, methodOrFallback?: any, silent * ## defineModule * Used to register an internal modularization component. */ -export const defineModule = (module: T): T => { +export const defineModule = >(module: T): T => { registerPlugin(module); return module; } @@ -130,7 +135,7 @@ export function attachStatics(TempoClass: any, props: Record) { export const defineInterpreterModule = (name: string, logic: any, statics?: Record) => defineModule({ name, - install(this: Tempo, TempoClass: typeof Tempo) { + install(this: TempoType, TempoClass: TempoType) { const rt = getRuntime(); const modules = rt.modules; @@ -166,7 +171,7 @@ export const defineInterpreterModule = (name: string, logic: any, statics?: Reco * ## defineExtension * Used to register a class-augmenting extension. */ -export const defineExtension = (extension: T): T => { +export const defineExtension = >(extension: T): T => { registerPlugin(extension); return extension; } diff --git a/packages/tempo/src/plugin/term/term.index.ts b/packages/tempo/src/plugin/term/term.index.ts index 685f296..b3de6d3 100644 --- a/packages/tempo/src/plugin/term/term.index.ts +++ b/packages/tempo/src/plugin/term/term.index.ts @@ -8,12 +8,12 @@ import { TimelineTerm } from './term.timeline.js' /** collection of built-in terms for initial registration */ export const StandardTerms = [QuarterTerm, SeasonTerm, ZodiacTerm, TimelineTerm]; -export { defineTerm, defineRange, getTermRange } from '../term.util.js'; +export { defineTerm, defineRange, getTermRange } from './term.util.js'; /** Aggregator module for all standard Terms */ export const TermsModule = defineModule({ name: 'TermsModule', - install(this: Tempo, TempoClass: typeof Tempo) { + install(this: typeof Tempo, TempoClass: typeof Tempo) { getRuntime().modules['TermsModule'] = true; // mark as canonical module onRegistryReset(() => { TempoClass.extend(StandardTerms); }); TempoClass.extend(StandardTerms); diff --git a/packages/tempo/src/plugin/term/term.quarter.ts b/packages/tempo/src/plugin/term/term.quarter.ts index b1fec7c..e4ffee5 100644 --- a/packages/tempo/src/plugin/term/term.quarter.ts +++ b/packages/tempo/src/plugin/term/term.quarter.ts @@ -1,4 +1,4 @@ -import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; +import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from './term.util.js'; import { COMPASS } from '../../support/support.enum.js'; import { isNumber } from '#library/assertion.library.js'; import { asArray } from '#library'; diff --git a/packages/tempo/src/plugin/term/term.season.ts b/packages/tempo/src/plugin/term/term.season.ts index 02321db..e7b9e92 100644 --- a/packages/tempo/src/plugin/term/term.season.ts +++ b/packages/tempo/src/plugin/term/term.season.ts @@ -1,4 +1,4 @@ -import { getTermRange, defineTerm, defineRange, resolveCycleWindow } from '../term.util.js'; +import { getTermRange, defineTerm, defineRange, resolveCycleWindow } from './term.util.js'; import { COMPASS } from '../../support/support.enum.js'; import type { Tempo } from '../../tempo.class.js'; diff --git a/packages/tempo/src/plugin/term/term.timeline.ts b/packages/tempo/src/plugin/term/term.timeline.ts index b23f49c..c7e3026 100644 --- a/packages/tempo/src/plugin/term/term.timeline.ts +++ b/packages/tempo/src/plugin/term/term.timeline.ts @@ -1,4 +1,4 @@ -import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; +import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from './term.util.js'; import type { Tempo } from '../../tempo.class.js'; /** definition of daily time periods */ diff --git a/packages/tempo/src/plugin/term/term.type.ts b/packages/tempo/src/plugin/term/term.type.ts new file mode 100644 index 0000000..648acac --- /dev/null +++ b/packages/tempo/src/plugin/term/term.type.ts @@ -0,0 +1,71 @@ +import type { Prettify, Property } from '#library/type.library.js'; +import type { Tempo } from '../../tempo.class.js'; +import { TermError } from '#tempo/support'; + +/** + * ## TempoTermType + * Specialized Tempo class type including term resolution error handling. + */ +export type TempoTermType = typeof Tempo & { + [TermError]?: (config: any, term: string) => void; +} + +/** + * ## TermPlugin + * Interface for term-driven parsing and resolution. + */ +export interface TermPlugin { + key: string; + scope?: string; + description?: string; + groups?: any; + ranges?: any[]; + resolve?: (this: Tempo, anchor?: any) => Range[]; + define: (this: Tempo, keyOnly?: boolean, anchor?: any) => string | Range | Range[] | undefined; +} + +/** mapping of terms to their resolved values */ +export type Terms = Property; + +/** + * ## Range + * Discrete time interval within a specific term. + * + * When Range.year is a number it is interpreted as a relative offset if |year| ≤ 10 + * and as an absolute year otherwise. + */ +export type Range = Prettify<{ + key: string; + group?: string; // categorization marker (e.g. 'western', 'chinese', 'fiscal') + [meta: string]: any; +} & ( + { year: number } | { month: number } | { week: number } | { day: number } | + { hour: number } | { minute: number } | { second: number } | + { millisecond: number } | { microsecond: number } | { nanosecond: number } + ) & { + year?: number; + month?: number; + week?: number; + day?: number; + hour?: number; + minute?: number; + second?: number; + millisecond?: number; + microsecond?: number; + nanosecond?: number; + }>; + + +/** + * ## ResolvedRange + * Range with additional metadata. + */ +export type ResolvedRange = Range & { + start: Tempo; + end: Tempo; + scope?: string; + label?: string; + unit?: string; + rollover?: string; + [str: string]: any; +} diff --git a/packages/tempo/src/plugin/term.util.ts b/packages/tempo/src/plugin/term/term.util.ts similarity index 98% rename from packages/tempo/src/plugin/term.util.ts rename to packages/tempo/src/plugin/term/term.util.ts index c44f88b..77c1f14 100644 --- a/packages/tempo/src/plugin/term.util.ts +++ b/packages/tempo/src/plugin/term/term.util.ts @@ -3,9 +3,9 @@ import { isDefined, isFunction, isString, isUndefined, isNumber, isZonedDateTime import { secure } from '#library/proxy.library.js'; import { sortKey, byKey } from '#library/array.library.js'; import { sym, TermError, SCHEMA, getLargestUnit, isTempo, getRuntime } from '#tempo/support'; -import type { Tempo } from '../tempo.class.js'; -import type { TermPlugin, Range, ResolvedRange } from './plugin.type.js'; -import { getHost } from './plugin.util.js'; +import type { Tempo } from '../../tempo.class.js'; +import type { TermPlugin, Range, ResolvedRange } from './term.type.js'; +import { getHost } from '../plugin.util.js'; /** * ## defineTerm @@ -373,7 +373,7 @@ export function resolveCycleWindow(source: Tempo | any, template: Range[] | Reco // Normalize year semantics: Treat small offsets as relative to the cycle, // while treating larger numbers as absolute years (e.g. for fixed historical dates). if (isNumber(itm.year)) { - clone.year = (itm.year >= -10 && itm.year <= 10) ? itm.year + targetYY : itm.year; // See Range JSDoc in plugin.type.ts (|year| ≤ 10 is relative) + clone.year = (itm.year >= -10 && itm.year <= 10) ? itm.year + targetYY : itm.year; // See Range JSDoc in term.type.ts (|year| ≤ 10 is relative) } else { clone.year = targetYY; } diff --git a/packages/tempo/src/plugin/term/term.zodiac.ts b/packages/tempo/src/plugin/term/term.zodiac.ts index 1ce0ad2..dd1e85d 100644 --- a/packages/tempo/src/plugin/term/term.zodiac.ts +++ b/packages/tempo/src/plugin/term/term.zodiac.ts @@ -1,4 +1,4 @@ -import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; +import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from './term.util.js'; import { isNumber } from '#library/assertion.library.js'; import type { Tempo } from '../../tempo.class.js'; diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index ce55d52..6ab791f 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -1,6 +1,7 @@ import { looseIndex } from '#library/object.library.js'; import { secure, proxify } from '#library/proxy.library.js'; import { getDateTimeFormat } from '#library/international.library.js'; +import { getTemporalIds } from '#library/temporal.library.js'; import { NUMBER, MODE, MONTH_DAY } from './support.enum.js'; import { Token } from './support.symbol.js'; diff --git a/packages/tempo/src/support/support.runtime.ts b/packages/tempo/src/support/support.runtime.ts index df2fbcc..48fdd6e 100644 --- a/packages/tempo/src/support/support.runtime.ts +++ b/packages/tempo/src/support/support.runtime.ts @@ -1,5 +1,6 @@ import { sym } from './support.symbol.js'; -import type { TermPlugin, Extension, Plugin } from '../plugin/plugin.type.js'; +import type { TermPlugin } from '../plugin/term/term.type.js'; +import type { Extension, Plugin } from '../plugin/plugin.type.js'; import type { Internal } from '../tempo.type.js'; /** diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 3004eb3..1916738 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -13,16 +13,17 @@ import { pad, trimAll } from '#library/string.library.js'; import { getType } from '#library/type.library.js'; import { clone } from '#library/serialize.library.js'; import { isEmpty, isDefined, isUndefined, isString, isObject, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike } from '#library/assertion.library.js'; -import { instant } from '#library/temporal.library.js'; +import { instant, getTemporalIds } from '#library/temporal.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; import type { Property, Secure } from '#library/type.library.js'; -import { registerPlugin, interpret, ensureModule } from './plugin/plugin.util.js' -import { registerTerm, getTermRange } from './plugin/term.util.js'; -import type { TermPlugin, Plugin } from './plugin/plugin.type.js'; +import { registerPlugin, interpret, ensureModule, type TempoPlugin } from './plugin/plugin.util.js' +import { registerTerm, getTermRange } from './plugin/term/term.util.js'; +import type { TermPlugin } from './plugin/term/term.type.js'; import { AliasEngine } from './engine/engine.alias.js'; import { PatternCompiler } from './engine/engine.pattern.js'; +import { createMasterGuard } from './engine/engine.guard.js'; import { resolveMonthDay, setProperty, proto, hasOwn, normalizeLayoutOrder } from './support/support.util.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './engine/engine.layout.js'; import { datePattern } from './support/support.default.js'; @@ -36,8 +37,11 @@ declare module '#library/type.library.js' { } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ /** current execution context */ const Context = getContext(); -/** return whether the shape is 'local' or 'global' */ const isLocal = (shape: { config: { scope: string } }) => shape.config.scope === 'local'; /** */ const ClassStates = new WeakMap(); +// shortcut functions to common Tempo properties / methods +/** current timestamp (ts) */ export const getStamp = ((tempo: t.DateTime, options: t.Options) => new Tempo(tempo, options).ts) as t.Params; +/** create new Tempo */ export const getTempo = ((tempo: t.DateTime, options: t.Options) => new Tempo(tempo, options)) as t.Params; +/** format a Tempo */ export const fmtTempo = ((fmt: string, tempo: t.DateTime, options: t.Options) => new Tempo(tempo, options).format(fmt as any)) as Internal.Fmt; // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ namespace Internal { // ...existing code... @@ -47,7 +51,7 @@ namespace Internal { export type Config = t.Internal.Config; export type Discovery = t.Internal.Discovery; export type Registry = t.Internal.Registry; - export type PluginContainer = t.Internal.PluginContainer; + export interface PluginContainer extends TempoPlugin { } export type Fmt = { // used for the fmtTempo() shortcut (fmt: F, tempo?: t.DateTime, options?: t.Options): t.FormatType; @@ -93,7 +97,6 @@ export class Tempo { /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); /** flag to prevent recursion during init */ static #lifecycle = { bootstrap: true, initialising: false, extendDepth: 0, ready: false }; /** Master Guard predicate (implements RegExp-like interface) */static #guard: { test(str: string): boolean } = { test: () => true }; - /** Set of allowed lowercased tokens for the Master Guard */ static #allowedTokens: Set = new Set(); static [$IsBase] = true; @@ -430,70 +433,9 @@ export class Tempo { ...Tempo.#terms.map(t => t.key), ...Tempo.#terms.map(t => t.scope), ...Guard - ].filter(w => isString(w) || isSymbol(w)) - .map(w => (isSymbol(w) ? w.description : (w as string))!.toLowerCase()) - .filter(Boolean); - - Tempo.#allowedTokens = new Set(wordsList); - - let maxT = 0; - for (const w of wordsList) if (w.length > maxT) maxT = w.length; - const maxTokenLength = maxT; - - // Define the custom guard logic (Scan-and-Consume) - Tempo.#guard = { - test(input: string): boolean { - if (!input || typeof input !== 'string') return false; + ]; - let i = 0; - const len = input.length; - - while (i < len) { - const char = input[i]; - - // 1. Skip spaces - if (char === ' ' || char === '\n' || char === '\t' || char === '\r') { - i++; - continue; - } - - // 2. Try Bracket match (starts with [) - if (char === '[') { - const sub = input.substring(i); - const match = sub.match(Match.bracket); - if (match && match.index === 0) { - i += match[0].length; - continue; - } - } - - // 3. Try Longest Token match from Set - let matched = false; - const searchLen = Math.min(maxTokenLength, len - i); - const slice = input.substring(i, i + searchLen).toLowerCase(); - - for (let l = searchLen; l > 0; l--) { - const candidate = slice.substring(0, l); - if (Tempo.#allowedTokens.has(candidate)) { - i += l; - matched = true; - break; - } - } - if (matched) continue; - - // 4. Try Fallback char (Match.guard) - if (Match.guard.test(char)) { - i++; - continue; - } - - return false; // No valid match at current position - } - - return true; - } - } + Tempo.#guard = createMasterGuard(wordsList); if ((this as any)[$Internal]() === Tempo.#global) { setPatterns((this as any)[$Internal]()); @@ -512,14 +454,14 @@ export class Tempo { * @param plugin - A plugin or term extension to register. * @param options - Optional configuration for the plugin. */ - static extend(plugin: Plugin, options?: t.Options): typeof Tempo; + static extend(plugin: TempoPlugin, options?: t.Options): typeof Tempo; /** * Register an array of plugins or term extensions. * * @param plugins - An array of plugins, terms, or extensions to register. * @param options - Optional configuration for the plugins. */ - static extend(plugins: (Plugin | TermPlugin | any)[], options?: t.Options): typeof Tempo; + static extend(plugins: (TempoPlugin | TermPlugin | any)[], options?: t.Options): typeof Tempo; /** * Register multiple plugins or term extensions. * @@ -564,7 +506,7 @@ export class Tempo { rt.installed.add(name); registerPlugin(item); - (item as Plugin).install.call(this as any, this); + (item as TempoPlugin).install.call(this as any, this); } else if (isObject(item)) { // 1. handle TermPlugin @@ -889,7 +831,8 @@ export class Tempo { static regexp(layout: string | RegExp, snippet?: Snippet) { const state = (this as any)[$Internal](); - state.patternCompiler ??= new PatternCompiler({ state }); + if (!state.patternCompiler || state.patternCompiler.state !== state) + state.patternCompiler = new PatternCompiler({ state }); return state.patternCompiler.compileRegExp(layout, snippet as any); } @@ -1041,6 +984,8 @@ export class Tempo { /** constructor options */ #options = {} as t.Options; /** instantiation Temporal Instant */ #now: Temporal.Instant; /** underlying Temporal ZonedDateTime */ #zdt!: Temporal.ZonedDateTime; + /** memoized TimeZone ID */ #tz?: string; + /** memoized Calendar ID */ #cal?: string; /** indicator that the instance failed to parse */ #errored = false; /** temporary anchor used during parsing */ #anchor: Temporal.ZonedDateTime | undefined; /** prebuilt formats, for convenience */ #fmt!: any; @@ -1187,6 +1132,14 @@ export class Tempo { return (this.constructor as typeof Tempo).hasModule(name); } + /** returns a [timezone, calendar] tuple derived from the underlying date-time. */ + #temporalIds(): [string, string] { + if (!this.#tz || !this.#cal) { + [this.#tz, this.#cal] = getTemporalIds(this.toDateTime()); + } + return [this.#tz, this.#cal]; + } + /** Resolve the instance to a Temporal.ZonedDateTime (with optional callback) */ #resolve(cb?: (zdt: Temporal.ZonedDateTime) => T): T | Temporal.ZonedDateTime { if (!this.#zdt) { @@ -1344,8 +1297,8 @@ export class Tempo { /** Microseconds of the millisecond (0-999) */ get us() { return this.toDateTime().microsecond as t.us } /** Nanoseconds of the microsecond (0-999) */ get ns() { return this.toDateTime().nanosecond as t.ns } /** Fractional seconds (e.g., 0.123456789) */ get ff() { return +(`0.${pad(this.ms, 3)}${pad(this.us, 3)}${pad(this.ns, 3)}`) } - /** IANA Time Zone ID (e.g., 'Australia/Sydney') */ get tz() { return this.toDateTime().timeZoneId } - /** Temporal Calendar ID (e.g., 'iso8601' | 'gregory') */ get cal() { return this.toDateTime().calendarId } + /** IANA Time Zone ID (e.g., 'Australia/Sydney') */ get tz() { return this.#temporalIds()[0] } + /** Temporal Calendar ID (e.g., 'iso8601' | 'gregory') */ get cal() { return this.#temporalIds()[1] } /** Unix timestamp (defaults to milliseconds) */ get ts() { return this.epoch[this.#local.config.timeStamp] } /** Short month name (e.g., 'Jan') */ get mmm() { return Tempo.MONTH.keyOf(this.toDateTime().month as t.Month) } /** Full month name (e.g., 'January') */ get mon() { return Tempo.MONTHS.keyOf(this.toDateTime().month as t.Month) } @@ -1457,7 +1410,10 @@ export class Tempo { /** returns a Temporal.PlainDateTime representation */ toPlainDateTime() { return this.toDateTime().toPlainDateTime() } /** returns the underlying Temporal.Instant */ toInstant() { return this.toDateTime().toInstant() } - /** the current system time localized to this instance. */toNow() { return instant().toZonedDateTimeISO(this.tz).withCalendar(this.cal) } + /** the current system time localized to this instance. */toNow() { + const [tz, cal] = getTemporalIds(this.#local.config.timeZone, this.#local.config.calendar); + return instant().toZonedDateTimeISO(tz).withCalendar(cal); + } /** the date-time as a standard `Date` object. */ toDate() { return new Date(this.toDateTime().round({ smallestUnit: enums.ELEMENT.ms }).epochMilliseconds) } /** Custom JSON serialization for `JSON.stringify`. */ toJSON() { return { ...this.#local.config, value: this.toString() } } /** iso8601 string representation of the date-time. */ @@ -1568,10 +1524,6 @@ export class Tempo { } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -// shortcut functions to common Tempo properties / methods -/** current timestamp (ts) */ export const getStamp = ((tempo: t.DateTime, options: t.Options) => new Tempo(tempo, options).ts) as t.Params; -/** create new Tempo */ export const getTempo = ((tempo: t.DateTime, options: t.Options) => new Tempo(tempo, options)) as t.Params; -/** format a Tempo */ export const fmtTempo = ((fmt: string, tempo: t.DateTime, options: t.Options) => new Tempo(tempo, options).format(fmt as any)) as Internal.Fmt; export namespace Tempo { export type DateTime = t.DateTime; diff --git a/packages/tempo/src/tempo.index.ts b/packages/tempo/src/tempo.index.ts index f400aba..a2fb30e 100644 --- a/packages/tempo/src/tempo.index.ts +++ b/packages/tempo/src/tempo.index.ts @@ -20,8 +20,7 @@ onRegistryReset(() => { Tempo.extend(core); -export { parse } from './discrete/discrete.parse.js'; -export { format } from './discrete/discrete.format.js'; +export { parse, format } from '#tempo/module'; export { enums }; export * from './tempo.class.js'; diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index c8a7ef8..6f777d0 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -12,10 +12,11 @@ import * as enums from '#tempo/support/support.enum.js'; import type { Logify } from '#library/logify.class.js'; import type { Snippet, Layout, Event, Period, Ignore } from '#tempo/support/support.default.js'; import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue, RegistryOption } from '#library/type.library.js'; -import type { TermPlugin, Plugin } from '#tempo/plugin/plugin.type.js'; +import type { TermPlugin } from '#tempo/plugin/term/term.type.js'; +import type { TempoPlugin } from '#tempo/plugin/plugin.util.js'; import type { Token } from '#tempo/support/support.symbol.js'; import type { Tempo } from '#tempo/tempo.class.js'; -import { AliasEngine } from './engine/engine.alias.js'; +import type { AliasEngine } from './engine/engine.alias.js'; import type { PatternCompiler } from './engine/engine.pattern.js'; declare global { @@ -199,7 +200,7 @@ export namespace Internal { /** custom time aliases (periods). */ period: Period | RegistryOption; /** noise words to ignore during parsing. */ ignore: Ignore; /** custom format strings to merge in the FORMAT enum */formats: Property; - /** plugins to be automatically extended */ plugins: Plugin | Plugin[]; + /** plugins to be automatically extended */ plugins: TempoPlugin | TempoPlugin[]; /** supplied value to parse */ value: DateTime; /** @internal temporary anchor used during parsing */ anchor: any; /** @internal accumulated parse results */ result?: Match[] | undefined; @@ -209,7 +210,7 @@ export namespace Internal { export type TimeStamp = 'ss' | 'ms' | 'us' | 'ns' /** internal metadata for a plugin to track installation */ - export interface PluginContainer extends Plugin { + export interface PluginContainer extends TempoPlugin { installed?: boolean; } @@ -284,7 +285,7 @@ export namespace Internal { /** term plugins to be registered via Tempo.addTerm() */terms?: TermPlugin | TermPlugin[]; /** custom format strings to merge in the FORMAT dictionary */formats?: Property; /** noise words to ignore during parsing via Tempo.ignore() */ ignore?: Ignore - /** plugins to be automatically extended via Tempo.extend() */plugins?: Plugin | Plugin[]; + /** plugins to be automatically extended via Tempo.extend() */plugins?: TempoPlugin | TempoPlugin[]; } } diff --git a/packages/tempo/src/tsconfig.json b/packages/tempo/src/tsconfig.json index cdef1a9..8442bcd 100644 --- a/packages/tempo/src/tsconfig.json +++ b/packages/tempo/src/tsconfig.json @@ -14,9 +14,9 @@ "#server/*": ["../../library/src/server/*"], "#tempo": ["./tempo.index.ts"], "#tempo/core": ["./core.index.ts"], - "#tempo/parse": ["./discrete/discrete.parse.ts"], - "#tempo/format": ["./discrete/discrete.format.ts"], - "#tempo/discrete": ["./discrete/discrete.index.ts"], + "#tempo/parse": ["./module/module.parse.ts"], + "#tempo/format": ["./module/module.format.ts"], + "#tempo/discrete": ["./module/module.index.ts"], "#tempo/duration": ["./module/module.duration.ts"], "#tempo/mutate": ["./module/module.mutate.ts"], "#tempo/ticker": ["./plugin/extend/extend.ticker.ts"], diff --git a/packages/tempo/test/README.md b/packages/tempo/test/README.md index df3b1b4..af8dd3e 100644 --- a/packages/tempo/test/README.md +++ b/packages/tempo/test/README.md @@ -10,7 +10,7 @@ This directory contains the automated test suite for the `@magmacomputing/tempo` | **`engine/`** | The internal parsing engine: lexer, planner, layout resolution, and pattern matching. | | **`instance/`** | Public methods on the `Tempo` instance (e.g., `add()`, `since()`, `format()`, `set()`). | | **`plugins/`** | Extension modules and registries: Terms, Tickers, and Duration modules. | -| **`discrete/`** | Standalone helper functions that operate independently of a `Tempo` instance. | +| **`module/`** | Modules (Parse, Format, Duration, Mutate) that can operate on a `Tempo` instance. | | **`issues/`** | Regression tests linked to specific bug reports or edge cases. | | **`support/`** | Infrastructure, Vitest setup files, and general test utilities. | diff --git a/packages/tempo/test/core/tempo_guard.test.ts b/packages/tempo/test/core/tempo_guard.test.ts index 76ad331..e832eb9 100644 --- a/packages/tempo/test/core/tempo_guard.test.ts +++ b/packages/tempo/test/core/tempo_guard.test.ts @@ -38,7 +38,12 @@ describe('Master Guard Extension', () => { // 3. '@@@banana@@@' now passes guard const t = new Tempo('@@@banana@@@'); - // expect(t.parse.lazy).toBe(true); + }); + + it('should permit numeric inputs which bypass the guard', () => { + const t = new Tempo(20260507); expect(t).toBeInstanceOf(Tempo); + // Accessing a property triggers the parse + expect(t.yy).toBe(2026); }); }); diff --git a/packages/tempo/test/engine/engine.guard.test.ts b/packages/tempo/test/engine/engine.guard.test.ts new file mode 100644 index 0000000..93d2abe --- /dev/null +++ b/packages/tempo/test/engine/engine.guard.test.ts @@ -0,0 +1,63 @@ +import { createMasterGuard } from '#tempo/engine/engine.guard.js'; + +describe('engine.guard (Master Guard)', () => { + it('should permit tokens from the provided word list', () => { + const guard = createMasterGuard(['apple', 'banana', 'cherry']); + expect(guard.test('apple')).toBe(true); + expect(guard.test('banana')).toBe(true); + expect(guard.test('cherry')).toBe(true); + expect(guard.test('apple banana')).toBe(true); + }); + + it('should reject unrecognized tokens', () => { + const guard = createMasterGuard(['apple', 'banana']); + expect(guard.test('apple grape')).toBe(false); + expect(guard.test('date')).toBe(false); + }); + + it('should handle greedy longest-token matching', () => { + const guard = createMasterGuard(['jan', 'january']); + // Should consume 'january' as one token, not 'jan' + 'uary' (which would fail) + expect(guard.test('january')).toBe(true); + expect(guard.test('jan')).toBe(true); + }); + + it('should skip valid bracketed content', () => { + const guard = createMasterGuard(['apple']); + // [any content] should be skipped by the bracket matcher + expect(guard.test('apple [random text]')).toBe(true); + expect(guard.test('[2026] apple')).toBe(true); + }); + + it('should permit fallback characters (digits and punctuation)', () => { + const guard = createMasterGuard(['utc']); + // Digits, hyphens, colons, and dots are usually allowed by Match.guard + expect(guard.test('2026-05-07 utc 13:00')).toBe(true); + }); + + it('should be case-insensitive', () => { + const guard = createMasterGuard(['Monday']); + expect(guard.test('monday')).toBe(true); + expect(guard.test('MONDAY')).toBe(true); + expect(guard.test('MonDay')).toBe(true); + }); + + it('should ignore various whitespace characters', () => { + const guard = createMasterGuard(['token']); + expect(guard.test('token\n \t\r token')).toBe(true); + }); + + it('should handle symbols in the word list', () => { + const sym = Symbol.for('test.token'); + const guard = createMasterGuard(['apple', sym]); + expect(guard.test('apple test.token')).toBe(true); + }); + + it('should fail on empty or non-string input', () => { + const guard = createMasterGuard(['apple']); + expect(guard.test('')).toBe(false); + expect(guard.test(' ')).toBe(false); + expect(guard.test(null as any)).toBe(false); + expect(guard.test(123 as any)).toBe(false); + }); +}); diff --git a/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts b/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts index dc0a5de..81a8181 100644 --- a/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts +++ b/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts @@ -20,7 +20,7 @@ describe('parse prefilter numeric safety constraints', () => { const first = t.parse.result?.[0] as any; // Using a delimiter ('-') ensures selectLayoutPatterns() is exercised instead of - // the pure numeric short-circuit (BigInt) in discrete.parse.ts. + // the pure numeric short-circuit (BigInt) in module.parse.ts. expect(first?.match).toBe('yearMonthDay'); expect(t.yy).toBe(1959); expect(t.mm).toBe(5); diff --git a/packages/tempo/test/support/setup.console-spy.ts b/packages/tempo/test/support/setup.console-spy.ts index 712f735..64d843c 100644 --- a/packages/tempo/test/support/setup.console-spy.ts +++ b/packages/tempo/test/support/setup.console-spy.ts @@ -2,11 +2,11 @@ import { vi, afterAll, beforeEach } from 'vitest'; // Named spies for each console method export const spies = { - error: vi.spyOn(console, 'error').mockImplementation(() => {}), - warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), - debug: vi.spyOn(console, 'debug').mockImplementation(() => {}), - log: vi.spyOn(console, 'log').mockImplementation(() => {}), - info: vi.spyOn(console, 'info').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => { }), + warn: vi.spyOn(console, 'warn').mockImplementation(() => { }), + debug: vi.spyOn(console, 'debug').mockImplementation(() => { }), + log: vi.spyOn(console, 'log').mockImplementation(() => { }), + info: vi.spyOn(console, 'info').mockImplementation(() => { }), } beforeEach(() => { diff --git a/packages/tempo/test/tsconfig.json b/packages/tempo/test/tsconfig.json index 3125f1a..44340a7 100644 --- a/packages/tempo/test/tsconfig.json +++ b/packages/tempo/test/tsconfig.json @@ -35,13 +35,13 @@ "../src/module/module.mutate.ts" ], "#tempo/format": [ - "../src/discrete/discrete.format.ts" + "../src/module/module.format.ts" ], "#tempo/parse": [ - "../src/discrete/discrete.parse.ts" + "../src/module/module.parse.ts" ], - "#tempo/discrete": [ - "../src/discrete/discrete.index.ts" + "#tempo/module": [ + "../src/module/module.index.ts" ], "#tempo/ticker": [ "../src/plugin/extend/extend.ticker.ts" diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index ee931ee..ed19a4e 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -36,8 +36,8 @@ export default defineConfig({ { find: /^#tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') }, { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './dist/module/module.duration.js') }, - { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './dist/discrete/discrete.$1.js') }, - { find: /^#tempo\/discrete$/, replacement: resolve(__dirname, './dist/discrete/discrete.index.js') }, + { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './dist/module/module.$1.js') }, + { find: /^#tempo\/module$/, replacement: resolve(__dirname, './dist/module/module.index.js') }, { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './dist/module/module.mutate.js') }, { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.ticker.js') }, { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.js') }, @@ -57,8 +57,8 @@ export default defineConfig({ { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/$1') }, { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './src/plugin/extend/extend.ticker.ts') }, { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './src/module/module.duration.ts') }, - { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './src/discrete/discrete.$1.ts') }, - { find: /^#tempo\/discrete$/, replacement: resolve(__dirname, './src/discrete/discrete.index.ts') }, + { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './src/module/module.$1.ts') }, + { find: /^#tempo\/module$/, replacement: resolve(__dirname, './src/module/module.index.ts') }, { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './src/module/module.mutate.ts') }, { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.ts') }, { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/plugin.$1.ts') },