From aadb7da8d5bc100615a3b98bc3b70426d0a04632 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 2 May 2026 19:04:34 +1000 Subject: [PATCH 01/29] chore: prepare Release D (v2.9.0) --- package.json | 2 +- packages/library/package.json | 2 +- packages/tempo/package.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 398ec0a..9c49f85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.8.0", + "version": "2.9.0", "private": true, "description": "Magma Computing Monorepo", "repository": { diff --git a/packages/library/package.json b/packages/library/package.json index 870ab1a..9915cf7 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.8.0", + "version": "2.9.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 0b7c6b8..c9848cd 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.8.0", + "version": "2.9.0", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -238,7 +238,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.8.0", + "@magmacomputing/library": "2.9.0", "@rollup/plugin-alias": "^6.0.0", "cross-env": "^7.0.3", "magic-string": "^0.30.21", From 79d3cace6b70f4d5df7d418b6ac468859ee2cfd6 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sat, 2 May 2026 19:23:33 +1000 Subject: [PATCH 02/29] fix doco --- package.json | 2 +- packages/library/package.json | 2 +- packages/tempo/archive/tempo.api.md | 6 +++--- packages/tempo/package.json | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 9c49f85..0e2644a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.9.0", + "version": "2.9.1", "private": true, "description": "Magma Computing Monorepo", "repository": { diff --git a/packages/library/package.json b/packages/library/package.json index 9915cf7..1ba9e56 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.9.0", + "version": "2.9.1", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/tempo/archive/tempo.api.md b/packages/tempo/archive/tempo.api.md index 6e7b5af..8a697dd 100644 --- a/packages/tempo/archive/tempo.api.md +++ b/packages/tempo/archive/tempo.api.md @@ -5,7 +5,7 @@ This document provides a comprehensive technical reference for the `Tempo` class --- - [TypeScript Types Reference](./tempo.types.md) -- [Tempo Cookbook](./tempo.cookbook.md) +- [Tempo Cookbook](../doc/tempo.cookbook.md) --- @@ -67,7 +67,7 @@ Retrieves or registers a `Symbol` for internal token mapping. ### `Tempo.ticker(arg1?, arg2?)` (Plugin required) Creates a reactive stream of `Tempo` instances at regular intervals. - **Returns:** An `AsyncGenerator` (if no callback) or a `stop` function (if callback provided). -- **See:** [Tempo Ticker Guide](./tempo.ticker.md) for the full polymorphic signature and usage patterns. +- **See:** [Tempo Ticker Guide](../doc/tempo.ticker.md) for the full polymorphic signature and usage patterns. ### `Tempo.regexp(layout, snippet?)` Translates a Tempo layout string into a compiled `RegExp`. @@ -201,5 +201,5 @@ Returns a `Temporal.PlainDateTime` representation. ::: tip **Looking for the full technical details?** -For an exhaustive, auto-generated reference of every property, internal type, and class member, see our [Full Technical API Reference](./api/README.md). +For an exhaustive, auto-generated reference of every property, internal type, and class member, see our [Full Technical API Reference](../doc/api/index.md). ::: diff --git a/packages/tempo/package.json b/packages/tempo/package.json index c9848cd..393b602 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "2.9.0", + "version": "2.9.1", "description": "The Tempo core library", "author": "Magma Computing Solutions", "license": "MIT", @@ -238,7 +238,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.9.0", + "@magmacomputing/library": "2.9.1", "@rollup/plugin-alias": "^6.0.0", "cross-env": "^7.0.3", "magic-string": "^0.30.21", From e462af785172484e14295c524e3251e069406dd7 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 3 May 2026 14:54:33 +1000 Subject: [PATCH 03/29] 1st draft PR --- packages/library/package.json | 2 +- packages/tempo/.vitepress/config.ts | 1 + packages/tempo/package.json | 2 +- packages/tempo/plan/.WISHLIST.md | 127 +++++++++++- packages/tempo/plan/RELEASE-D.md | 171 ++++++++++++++++ packages/tempo/src/engine/engine.alias.ts | 185 ++++++++++++++++++ .../src/engine/engine.layout.resolver.ts.bak | 0 .../tempo/src/engine/engine.layout.ts.bak | 0 packages/tempo/src/tempo.class.ts | 73 +++---- packages/tempo/src/tempo.type.ts | 2 + packages/tempo/test/core/alias-engine.test.ts | 103 ++++++++++ .../tempo/test/core/sandbox-factory.test.ts | 10 +- .../tempo/test/support/setup.console-spy.ts | 34 ++-- 13 files changed, 625 insertions(+), 85 deletions(-) create mode 100644 packages/tempo/plan/RELEASE-D.md create mode 100644 packages/tempo/src/engine/engine.alias.ts delete mode 100644 packages/tempo/src/engine/engine.layout.resolver.ts.bak delete mode 100644 packages/tempo/src/engine/engine.layout.ts.bak create mode 100644 packages/tempo/test/core/alias-engine.test.ts diff --git a/packages/library/package.json b/packages/library/package.json index 1ba9e56..789c165 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -88,7 +88,7 @@ "test": "vitest run", "build": "tsc -b", "clean": "tsc -b --clean", - "prepublishOnly": "npm run build" + "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build" }, "dependencies": { "tslib": "^2.8.1" diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 5a73754..82ad0fd 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -12,6 +12,7 @@ export default defineConfig({ base: '/magma/', title: "Tempo", description: "The Professional Date-Time Library for Temporal", + srcDir: './doc', markdown: { math: true }, diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 393b602..374ed6d 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -223,7 +223,7 @@ "build:resolve": "tsx bin/resolve-types.ts", "clean": "rm -rf dist && (tsc -b --clean || true)", "publish": "npm publish --access public", - "prepublishOnly": "npm run build", + "prepublishOnly": "if [ $(git rev-parse --abbrev-ref HEAD) != main ]; then echo 'ERROR: Must be on main branch to publish.'; exit 1; fi && npm run build", "docs:api": "typedoc", "docs:dev": "npm run build && npm run docs:api && vitepress dev", "docs:build": "npm run build && npm run docs:api && vitepress build", diff --git a/packages/tempo/plan/.WISHLIST.md b/packages/tempo/plan/.WISHLIST.md index e410313..24c7d41 100644 --- a/packages/tempo/plan/.WISHLIST.md +++ b/packages/tempo/plan/.WISHLIST.md @@ -155,13 +155,128 @@ Exit criteria: ### Release D: deeper decomposition cleanup -- [ ] Extract Pattern Compiler + Cache module. -- [ ] Extract Alias Resolution Engine module. -- [ ] Optional: extract Guard Builder and Parse Result Normalizer if churn justifies. +--- + +#### 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. -Exit criteria: -- Reduced cyclomatic complexity in primary class engine. -- Internal module contracts documented and covered by focused unit tests. +**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) diff --git a/packages/tempo/plan/RELEASE-D.md b/packages/tempo/plan/RELEASE-D.md new file mode 100644 index 0000000..f2caf2e --- /dev/null +++ b/packages/tempo/plan/RELEASE-D.md @@ -0,0 +1,171 @@ +# 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 +- [ ] Extract `compileRegExp`, `setPatterns`, and helpers to new module +- [ ] Integrate memoization/caching logic as needed +- [ ] Refactor engine and consumers to use new module +- [ ] Ensure compatibility with snippet/layout definitions +- [ ] Add/expand unit tests for pattern logic and cache +- [ ] Update documentation and references + +### Alias Resolution Engine Extraction +- [ ] Extract alias resolution logic to new module +- [ ] Define interfaces for registration, lookup, collision +- [ ] Refactor engine and plugins to use new APIs +- [ ] Add/expand unit tests for alias/collision +- [ ] 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/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts new file mode 100644 index 0000000..37744cb --- /dev/null +++ b/packages/tempo/src/engine/engine.alias.ts @@ -0,0 +1,185 @@ +// engine.alias.ts +// Alias Resolution Engine for Tempo +// Responsible for event/period alias mapping, collision detection, and snippet rebinding + +import { asType } from '#library/type.library.js'; +import type { Logify } from '#library/logify.class.js'; + +export type AliasTarget = string | Function + +export interface AliasEngineOptions { + parent?: AliasEngine | undefined; + logger?: Logify | undefined; +} + +export class AliasEngine { + #parentEngine?: AliasEngineOptions["parent"]; + #logger?: AliasEngineOptions["logger"]; + + constructor(options: AliasEngineOptions = {}) { + this.#parentEngine = options.parent; + this.#logger = options.logger; + } + + /** + * Detect likely overlap between two alias keys/patterns (moved from Tempo) + */ + static isAliasCollision(a: string, b: string): boolean { + const left = a.trim().toLowerCase(); + const right = b.trim().toLowerCase(); + + if (!left || !right) return false; + if (left === right) return true; + + // Extract the 'core' characters to determine if they conceptually target the same word + const getBaseWord = (s: string) => s + .replace(/\[[^\]]*\]\?/g, '') // remove optional character classes (e.g. [ -]?) + .replace(/.\?/g, '') // remove optional single characters (e.g. s?) + .replace(/[^a-z0-9]/g, ''); // remove all non-alphanumeric characters (regex metachars, spaces, hyphens) + + const baseLeft = getBaseWord(left); + const baseRight = getBaseWord(right); + + if (!baseLeft || !baseRight) return false; + + return baseLeft === baseRight; + } + + #eventMap: Map = new Map(); + #periodMap: Map = new Map(); + #eventCollisions: Map = new Map(); + #periodCollisions: Map = new Map(); + + // Event alias management + registerEventAlias(name: string, target: AliasTarget): void { + this.#registerAliasWithCollision(name, target, this.#eventMap, this.#eventCollisions, 'event'); + } + + registerEvents(events: [string, AliasTarget][]): void { + for (const [name, target] of events) + this.registerEventAlias(name, target); + } + resolveEventAlias(name: string, thisArg?: any) { + return this.#resolveAlias(name, this.#eventMap, thisArg); + } + hasEventAlias(name: string): boolean { + return this.#eventMap.has(name); + } + getAllEventAliases(): Record { + return Object.fromEntries(this.#eventMap.entries()); + } + detectEventCollisions(): Record { + return Object.fromEntries(this.#eventCollisions.entries()); + } + + // Period alias management + registerPeriodAlias(name: string, target: AliasTarget): void { + this.#registerAliasWithCollision(name, target, this.#periodMap, this.#periodCollisions, 'period'); + } + + registerPeriods(periods: [string, AliasTarget][]): void { + for (const [name, target] of periods) + this.registerPeriodAlias(name, target); + } + resolvePeriodAlias(name: string, thisArg?: any) { + return this.#resolveAlias(name, this.#periodMap, thisArg); + } + hasPeriodAlias(name: string): boolean { + return this.#periodMap.has(name); + } + getAllPeriodAliases(): Record { + return Object.fromEntries(this.#periodMap.entries()); + } + detectPeriodCollisions(): Record { + return Object.fromEntries(this.#periodCollisions.entries()); + } + + // Shared logic + #registerAliasWithCollision( + name: string, + target: AliasTarget, + map: Map, + collisions: Map, + type: 'event' | 'period' + ) { + let collisionDetected = false; + // Check for local collisions using isAliasCollision + for (const [existingName, existingTarget] of map.entries()) { + if ( + existingTarget !== target && + AliasEngine.isAliasCollision(existingName, name) + ) { + const existing = collisions.get(existingName) || []; + collisions.set( + existingName, + Array.from(new Set([...existing, target, existingTarget])) + ); + collisionDetected = true; + } + } + + // Check for parent collisions using isAliasCollision + let parent = this.#parentEngine; + while (parent) { + const parentMap = type === 'event' ? parent.#eventMap : parent.#periodMap; + for (const [parentName, parentTarget] of parentMap.entries()) { + if ( + parentTarget !== target && + AliasEngine.isAliasCollision(parentName, name) + ) { + const parentCollisions = collisions.get(parentName) || []; + collisions.set( + parentName, + Array.from(new Set([...parentCollisions, target, parentTarget])) + ); + collisionDetected = true; + } + } + parent = parent.#parentEngine; + } + + if (collisionDetected && this.#logger) { + this.#logger.warn( + `[AliasEngine] Potential Collision detected for ${type} alias "${name}". Multiple definitions found. This may shadow or overwrite an existing alias.` + ); + } + + map.set(name, target); + } + + #resolveAlias(name: string, map: Map, thisArg?: any): string { + let currentEngine: AliasEngine | undefined = this; + + while (currentEngine) { + const { type, value } = asType(map.get(name)); + switch (type) { + case 'Function': + return value.call(thisArg); + + case 'String': + return value; + + default: + currentEngine = currentEngine.#parentEngine; + map = currentEngine + ? (map === this.#eventMap + ? currentEngine.#eventMap + : currentEngine.#periodMap) + : map; + } + } + + return name; + } + + clear(type?: 'event' | 'period'): void { + if (!type || type === 'event') { + this.#eventMap.clear(); + this.#eventCollisions.clear(); + } + if (!type || type === 'period') { + this.#periodMap.clear(); + this.#periodCollisions.clear(); + } + } +} diff --git a/packages/tempo/src/engine/engine.layout.resolver.ts.bak b/packages/tempo/src/engine/engine.layout.resolver.ts.bak deleted file mode 100644 index e69de29..0000000 diff --git a/packages/tempo/src/engine/engine.layout.ts.bak b/packages/tempo/src/engine/engine.layout.ts.bak deleted file mode 100644 index e69de29..0000000 diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 510f7d3..8050b8a 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -13,14 +13,15 @@ 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 type { Property, Secure } from '#library/type.library.js'; import { instant } 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 { AliasEngine } from './engine/engine.alias.js'; import { resolveMonthDay } from './support/tempo.util.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './parse/parse.layout.js'; import { datePattern } from './support/tempo.default.js'; @@ -39,6 +40,7 @@ declare module '#library/type.library.js' { /** */ const ClassStates = new WeakMap(); // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ namespace Internal { + // ...existing code... export type State = t.Internal.State; export type Parse = t.Internal.Parse; export type MatchResult = t.Internal.Match; @@ -134,9 +136,6 @@ export class Tempo { Tempo.#dbg.debug(config, ...args); } - // ...rest of the class definition remains unchanged... - - /** * {dt} is a layout that combines date-related {snippets} (e.g. dd, mm -or- evt) into a pattern against which a string can be tested. * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event @@ -147,7 +146,12 @@ export class Tempo { if (isLocal(shape) && !hasOwn(shape.parse, 'event') && !hasOwn(shape.parse.monthDay, 'active')) return; // no local change needed - const src = shape.config.scope === 'global' ? 'g' : 'l'; // 'g'lobal or 'l'ocal (sandbox also uses 'l') + // Use the correct alias engine: static for global, instance for local, and assign parentEngine for locals + const engine = shape.aliasEngine ??= new AliasEngine({ parent: Tempo.#global.aliasEngine, logger: Tempo.#dbg }); + engine.clear('event'); + engine.registerEvents(events); + + const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') const groups = events .map(([pat, _], idx) => `(?<${src}evt${idx}>${pat})`) // assign a number to the pattern .join('|') // make an 'Or' pattern for the event-keys @@ -187,19 +191,14 @@ export class Tempo { static [$setPeriods](shape: Internal.State) { const periods = ownEntries(shape.parse.period, true); if (isLocal(shape) && !hasOwn(shape.parse, 'period')) - return; // no local change needed - - const src = shape.config.scope === 'global' ? 'g' : 'l'; // 'g'lobal or 'l'ocal (sandbox also uses 'l') + return; // no local change needed - // Check for alias collisions among period keys - const keys = periods.map(([pat]) => pat); - for (let i = 0; i < keys.length; i++) { - for (let j = i + 1; j < keys.length; j++) { - if (Tempo.#isAliasCollision(keys[i], keys[j])) - Tempo.#dbg.warn(`Potential period alias collision: "${keys[i]}" overlaps with existing alias(es): ${keys[j]}`); - } - } + // Use the correct alias engine: static for global, instance for local + const engine = shape.aliasEngine ??= new AliasEngine({ parent: Tempo.#global.aliasEngine, logger: Tempo.#dbg }); + engine.clear('period'); + engine.registerPeriods(periods); + const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') const groups = periods .map(([pat, _], idx) => `(?<${src}per${idx}>${pat})`) // {pattern} is the 1st element of the tuple .join('|') // make an 'or' pattern for the period-keys @@ -302,28 +301,6 @@ export class Tempo { locale // cannot determine locale } - /** detect likely overlap between two alias keys/patterns */ - static #isAliasCollision(a: string, b: string): boolean { - const left = a.trim().toLowerCase(); - const right = b.trim().toLowerCase(); - - if (!left || !right) return false; - if (left === right) return true; - - // Extract the 'core' characters to determine if they conceptually target the same word - const getBaseWord = (s: string) => s - .replace(/\[[^\]]*\]\?/g, '') // remove optional character classes (e.g. [ -]?) - .replace(/.\?/g, '') // remove optional single characters (e.g. s?) - .replace(/[^a-z0-9]/g, ''); // remove all non-alphanumeric characters (regex metachars, spaces, hyphens) - - const baseLeft = getBaseWord(left); - const baseRight = getBaseWord(right); - - if (!baseLeft || !baseRight) return false; - - return baseLeft === baseRight; - } - /** * conform input of Snippet / Layout / Event / Period options * This is needed because we allow the user to flexibly provide detail as {[key]:val} or {[key]:val}[] or [key,val][] @@ -540,13 +517,6 @@ export class Tempo { return proxify(omit({ ...discovery, scope: 'discovery' }, 'value')); } - /** - * Unified loader for library extensions. - * - * @param arg - A `Plugin` function, a `TermPlugin` object (or array), or a `Discovery` object. - * @param options - Optional configuration for a standard `Plugin`. - * @returns The `Tempo` class for chaining. - */ /** * Register a plugin or term extension. * @@ -1271,11 +1241,6 @@ export class Tempo { // shadowing chain (only if extensible) if (Reflect.isExtensible(target)) Object.defineProperty(target, name, { get, enumerable: true, configurable: true }); - // if (Reflect.isExtensible(target)) { - // const shadow = Object.create(Object.getPrototypeOf(target)); - // Object.defineProperty(shadow, name, { get, enumerable: true, configurable: true }); - // Object.setPrototypeOf(target, shadow); - // } return get; // return getter closure } @@ -1499,6 +1464,13 @@ export class Tempo { this.#local.parse.planner = { ...classState.parse.planner }; // clone the planner object setProperty(this.#local.parse, 'result', [...(options.result ?? [])]); + Object.defineProperty(this.#local, 'tempoInstance', { // Link this instance to its state for static alias access + value: this, + writable: false, + configurable: true, + enumerable: false + }); + (this.constructor as any)[$setConfig](this.#local, options); // set #local config } @@ -1623,4 +1595,3 @@ export namespace Tempo { export interface Params extends t.Params { } } - diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 78b9db5..e40460c 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -15,6 +15,7 @@ import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, import type { TermPlugin, Plugin } from '#tempo/plugin/plugin.type.js'; import type { Token } from '#tempo/support/tempo.symbol.js'; import type { Tempo } from '#tempo/tempo.class.js'; +import { AliasEngine } from './engine/engine.alias.js'; declare global { interface globalThis { @@ -223,6 +224,7 @@ export namespace Internal { /** @internal current anchor during parsing */ anchor?: Temporal.ZonedDateTime; /** @internal current ZonedDateTime during parsing */ zdt?: Temporal.ZonedDateTime; /** @internal has the parse operation errored? */ errored?: boolean; + /** @internal Alias engine for this Tempo instance */ aliasEngine?: AliasEngine; } /** debug a Tempo instantiation */ diff --git a/packages/tempo/test/core/alias-engine.test.ts b/packages/tempo/test/core/alias-engine.test.ts new file mode 100644 index 0000000..a24b8ed --- /dev/null +++ b/packages/tempo/test/core/alias-engine.test.ts @@ -0,0 +1,103 @@ +import { AliasEngine } from '#tempo/engine/engine.alias.js'; +import { Logify } from '#library/logify.class.js'; + +// Simple logger mock +const logger = new Logify({ debug: true }); +// const logger = { +// warn: vi.fn(), +// }; + +beforeEach(() => { + // logger.warn.mockClear(); +}); + +describe('AliasEngine', () => { + it('registers and resolves string and function aliases', () => { + const engine = new AliasEngine({ logger }); + engine.registerEventAlias('foo', 'bar'); + expect(engine.resolveEventAlias('foo')).toBe('bar'); + engine.registerPeriodAlias('noon', function () { return '12:00'; }); + expect(engine.resolvePeriodAlias('noon')).toBe('12:00'); + }); + + it('supports parent/child shadowing and fallback', () => { + const globalEngine = new AliasEngine({ logger }); + globalEngine.registerEventAlias('foo', 'bar'); + const localEngine = new AliasEngine({ parent: globalEngine, logger }); + expect(localEngine.resolveEventAlias('foo')).toBe('bar'); + localEngine.registerEventAlias('foo', 'baz'); + expect(localEngine.resolveEventAlias('foo')).toBe('baz'); + expect(globalEngine.resolveEventAlias('foo')).toBe('bar'); + }); + + it('warns on local/global collision', () => { + const globalEngine = new AliasEngine({ logger }); + globalEngine.registerPeriodAlias('noon', '12:00'); + const localEngine = new AliasEngine({ parent: globalEngine, logger }); + localEngine.registerPeriodAlias('noon', '11:00'); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('noon')); + }); + + it('warns on local collision', () => { + const engine = new AliasEngine({ logger }); + engine.registerEventAlias('foo', 'bar'); + engine.registerEventAlias('foo', 'baz'); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('foo')); + }); + + it('registers and resolves batch aliases', () => { + const engine = new AliasEngine({ logger }); + engine.registerEvents([ + ['a', '1'], + ['b', '2'], + ]); + expect(engine.resolveEventAlias('a')).toBe('1'); + expect(engine.resolveEventAlias('b')).toBe('2'); + }); + + it('clears only events or periods', () => { + const engine = new AliasEngine({ logger }); + engine.registerEventAlias('foo', 'bar'); + engine.registerPeriodAlias('noon', '12:00'); + engine.clear('event'); + expect(engine.resolveEventAlias('foo')).toBe('foo'); + expect(engine.resolvePeriodAlias('noon')).toBe('12:00'); + engine.clear('period'); + expect(engine.resolvePeriodAlias('noon')).toBe('noon'); + }); + + it('handles regex-like collision heuristics', () => { + const globalEngine = new AliasEngine({ logger }); + globalEngine.registerPeriodAlias('noon', '12:00'); + const localEngine = new AliasEngine({ parent: globalEngine, logger }); + localEngine.registerPeriodAlias('([after[ -]?])?noon', '11:00'); + + // This should warn, even if not a perfect regex match + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('noon')); + }); + + it('does not warn on non-colliding aliases', () => { + const engine = new AliasEngine({ logger }); + engine.registerEventAlias('foo', 'bar'); + engine.registerEventAlias('baz', 'qux'); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('resolves to parent after clear', () => { + const globalEngine = new AliasEngine({ logger }); + globalEngine.registerEventAlias('foo', 'bar'); + const localEngine = new AliasEngine({ parent: globalEngine, logger }); + localEngine.registerEventAlias('foo', 'baz'); + expect(localEngine.resolveEventAlias('foo')).toBe('baz'); + localEngine.clear('event'); + expect(localEngine.resolveEventAlias('foo')).toBe('bar'); + }); + + it('handles empty/optional/edge-case aliases', () => { + const engine = new AliasEngine({ logger }); + engine.registerEventAlias('', 'empty'); + expect(engine.resolveEventAlias('')).toBe('empty'); + engine.registerEventAlias('?', 'question'); + expect(engine.resolveEventAlias('?')).toBe('question'); + }); +}); diff --git a/packages/tempo/test/core/sandbox-factory.test.ts b/packages/tempo/test/core/sandbox-factory.test.ts index b88526b..a1052ef 100644 --- a/packages/tempo/test/core/sandbox-factory.test.ts +++ b/packages/tempo/test/core/sandbox-factory.test.ts @@ -1,4 +1,5 @@ import { Tempo } from '#tempo'; +import { spies } from '../support/setup.console-spy.js'; describe('Sandbox Factory Pattern', () => { it('should return a subclass when create is called with options', () => { @@ -24,12 +25,18 @@ describe('Sandbox Factory Pattern', () => { }); it('should support shadowing global aliases', () => { + const warnCountBefore = spies.warn.mock.calls.length; + // Global 'noon' is 12:00 const EarlyNoon = Tempo.create({ period: { 'noon': '11:00' } }); + + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalled(); + expect(spies.warn.mock.calls.length).toBe(warnCountBefore + 1); // Original remains unaffected (if not manually reset in a way that changes it) // We expect 12:00 for the base Tempo @@ -38,9 +45,6 @@ describe('Sandbox Factory Pattern', () => { const t2 = new EarlyNoon('noon'); expect(t2.hh).toBe(11); - - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); }); it('should record traceability info in parse results', () => { diff --git a/packages/tempo/test/support/setup.console-spy.ts b/packages/tempo/test/support/setup.console-spy.ts index 04de23c..712f735 100644 --- a/packages/tempo/test/support/setup.console-spy.ts +++ b/packages/tempo/test/support/setup.console-spy.ts @@ -1,30 +1,18 @@ import { vi, afterAll, beforeEach } from 'vitest'; -// Global console suppression for all tests -// (You can comment out lines to allow specific console methods) - -declare global { - // eslint-disable-next-line no-var - var _consoleSpies: Array>; - - // Note: To use mockClear/mockRestore on console methods in tests, use (console.error as any).mockClear() +// 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(() => {}), } -/** setup global console spies before all tests */ -globalThis._consoleSpies = [ - vi.spyOn(console, 'error').mockImplementation(() => { }), - vi.spyOn(console, 'warn').mockImplementation(() => { }), - vi.spyOn(console, 'debug').mockImplementation(() => { }), - vi.spyOn(console, 'log').mockImplementation(() => { }), - vi.spyOn(console, 'info').mockImplementation(() => { }), -]; - -/** restore global console spies after all tests */ -afterAll(() => { - globalThis._consoleSpies.forEach(spy => spy.mockRestore()); +beforeEach(() => { + Object.values(spies).forEach(spy => spy.mockClear()); }); -/** clear global console spies before each test */ -beforeEach(() => { - globalThis._consoleSpies.forEach(spy => (spy as any).mockClear()); +afterAll(() => { + Object.values(spies).forEach(spy => spy.mockRestore()); }); From 2af76987347181eb961a67fbf8c3eca1ebd54fb7 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 3 May 2026 15:29:13 +1000 Subject: [PATCH 04/29] 2nd draft PR review --- .github/workflows/ci.yml | 45 +++++++++++++++++ packages/tempo/src/engine/engine.alias.ts | 13 +++-- packages/tempo/src/tempo.class.ts | 6 ++- .../test/core/alias-engine-protochain.test.ts | 48 +++++++++++++++++++ 4 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 packages/tempo/test/core/alias-engine-protochain.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edfff3c..5a84ed0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,28 @@ jobs: timeout-minutes: 30 if: github.ref == 'refs/heads/release-c-layout-order-planner' || github.event.pull_request.base.ref == 'main' steps: + - name: Print GitHub event context (for debug) + run: | + echo "GITHUB_REF: $GITHUB_REF" + echo "GITHUB_EVENT_NAME: $GITHUB_EVENT_NAME" + echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF" + echo "GITHUB_BASE_REF: $GITHUB_BASE_REF" + echo "github.ref: ${{ github.ref }}" + echo "github.event_name: ${{ github.event_name }}" + echo "github.head_ref: ${{ github.head_ref }}" + echo "github.base_ref: ${{ github.base_ref }}" + echo "github.event.pull_request.base.ref: ${{ github.event.pull_request.base.ref }}" + echo "github.sha: ${{ github.sha }}" + echo "github.repository: ${{ github.repository }}" + echo "github.actor: ${{ github.actor }}" + echo "github.workflow: ${{ github.workflow }}" + echo "github.run_id: ${{ github.run_id }}" + echo "github.run_number: ${{ github.run_number }}" + echo "github.job: ${{ github.job }}" + echo "github.ref_name: ${{ github.ref_name }}" + echo "github.ref_type: ${{ github.ref_type }}" + echo "github.event: $(cat $GITHUB_EVENT_PATH)" + shell: bash - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 @@ -69,3 +91,26 @@ jobs: run: | node -e "const r=require('./packages/tempo/bench-output.json');if(!r.success){console.error('Benchmark failed:',r.errors);process.exit(1)}else{console.log('Benchmark passed.')}" working-directory: ${{ github.workspace }} + - name: Install monorepo dependencies + run: npm ci + working-directory: ${{ github.workspace }} + - name: Run all tests with parse.preFilter + run: npm test + working-directory: packages/tempo + env: + TEMPO_PREFILTER_CI: 'true' + - name: Run end-to-end benchmark + run: npx tsx --conditions=development bench/bench.parse.prefilter.e2e.ts > bench-output.json 2> bench-error.log + working-directory: packages/tempo + - name: Upload benchmark output + if: always() + uses: actions/upload-artifact@v4 + with: + name: bench-parse-prefilter-e2e + path: | + packages/tempo/bench-output.json + packages/tempo/bench-error.log + - name: Validate benchmark output + run: | + node -e "const r=require('./packages/tempo/bench-output.json');if(!r.success){console.error('Benchmark failed:',r.errors);process.exit(1)}else{console.log('Benchmark passed.')}" + working-directory: ${{ github.workspace }} diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 37744cb..37109b3 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -5,7 +5,7 @@ import { asType } from '#library/type.library.js'; import type { Logify } from '#library/logify.class.js'; -export type AliasTarget = string | Function +export type AliasTarget = string | number | Function export interface AliasEngineOptions { parent?: AliasEngine | undefined; @@ -147,8 +147,9 @@ export class AliasEngine { map.set(name, target); } - #resolveAlias(name: string, map: Map, thisArg?: any): string { + #resolveAlias(name: string, map: Map, thisArg?: any): string | number { let currentEngine: AliasEngine | undefined = this; + const isEvent = map === this.#eventMap; while (currentEngine) { const { type, value } = asType(map.get(name)); @@ -157,15 +158,13 @@ export class AliasEngine { return value.call(thisArg); case 'String': + case 'Number': return value; default: currentEngine = currentEngine.#parentEngine; - map = currentEngine - ? (map === this.#eventMap - ? currentEngine.#eventMap - : currentEngine.#periodMap) - : map; + if (currentEngine) + map = isEvent ? currentEngine.#eventMap : currentEngine.#periodMap; } } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 8050b8a..9cd7dcb 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -147,7 +147,8 @@ export class Tempo { return; // no local change needed // Use the correct alias engine: static for global, instance for local, and assign parentEngine for locals - const engine = shape.aliasEngine ??= new AliasEngine({ parent: Tempo.#global.aliasEngine, logger: Tempo.#dbg }); + const proto = Object.getPrototypeOf(shape); + const engine = shape.aliasEngine ??= new AliasEngine({ parent: proto.aliasEngine, logger: Tempo.#dbg }); engine.clear('event'); engine.registerEvents(events); @@ -194,7 +195,8 @@ export class Tempo { return; // no local change needed // Use the correct alias engine: static for global, instance for local - const engine = shape.aliasEngine ??= new AliasEngine({ parent: Tempo.#global.aliasEngine, logger: Tempo.#dbg }); + const proto = Object.getPrototypeOf(shape); + const engine = shape.aliasEngine ??= new AliasEngine({ parent: proto.aliasEngine, logger: Tempo.#dbg }); engine.clear('period'); engine.registerPeriods(periods); diff --git a/packages/tempo/test/core/alias-engine-protochain.test.ts b/packages/tempo/test/core/alias-engine-protochain.test.ts new file mode 100644 index 0000000..1cf32a5 --- /dev/null +++ b/packages/tempo/test/core/alias-engine-protochain.test.ts @@ -0,0 +1,48 @@ +import { AliasEngine } from '#tempo/engine/engine.alias.js'; +import { Logify } from '#library/logify.class.js'; + +describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => { + const logger = new Logify({ debug: true }); + + // Simulate a global state + const globalShape = {} as { aliasEngine: AliasEngine }; + globalShape.aliasEngine = new AliasEngine({ logger }); + globalShape.aliasEngine.registerEventAlias('globalEvt', 'globalValue'); + + // Simulate a sandbox state inheriting from global + const sandboxShape = Object.create(globalShape); + sandboxShape.aliasEngine = new AliasEngine({ parent: globalShape.aliasEngine, logger }); + sandboxShape.aliasEngine.registerEventAlias('sandboxEvt', 'sandboxValue'); + + // Simulate a local/instance state inheriting from sandbox + const localShape = Object.create(sandboxShape); + localShape.aliasEngine = new AliasEngine({ parent: sandboxShape.aliasEngine, logger }); + localShape.aliasEngine.registerEventAlias('localEvt', 'localValue'); + + it('resolves local, sandbox, and global aliases in correct order', () => { + // Local should resolve its own + expect(localShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localValue'); + // Local should resolve sandbox + expect(localShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxValue'); + // Local should resolve global + expect(localShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + // Sandbox should not see local + expect(sandboxShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localEvt'); + // Sandbox should resolve its own and global + expect(sandboxShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxValue'); + expect(sandboxShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + // Global should only resolve its own + expect(globalShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + expect(globalShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxEvt'); + expect(globalShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localEvt'); + }); + + it('collision detection traverses the prototype chain', () => { + // Register a colliding alias in local + localShape.aliasEngine.registerEventAlias('globalEvt', 'localShadow'); + // Should warn about collision with global + // (You may want to spy on logger.warn for a real assertion) + expect(localShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('localShadow'); + expect(globalShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + }); +}); From 945d84565e68233237f5be718d9518a3866c1b6e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 3 May 2026 20:00:15 +1000 Subject: [PATCH 05/29] 3rd draft PR review --- packages/tempo/src/engine/engine.alias.ts | 6 ++++-- packages/tempo/src/support/tempo.init.ts | 4 ++-- packages/tempo/src/tempo.class.ts | 12 ++++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 37109b3..cf5329d 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -121,8 +121,8 @@ export class AliasEngine { // Check for parent collisions using isAliasCollision let parent = this.#parentEngine; while (parent) { - const parentMap = type === 'event' ? parent.#eventMap : parent.#periodMap; - for (const [parentName, parentTarget] of parentMap.entries()) { + const parentAliases = type === 'event' ? parent.getAllEventAliases() : parent.getAllPeriodAliases(); + for (const [parentName, parentTarget] of Object.entries(parentAliases)) { if ( parentTarget !== target && AliasEngine.isAliasCollision(parentName, name) @@ -135,6 +135,8 @@ export class AliasEngine { collisionDetected = true; } } + // Access parent's parent engine via a public way if possible, or keep it private if safe + // Since we're in the same class, we can still use #parentEngine for the chain parent = parent.#parentEngine; } diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index 8de3be3..5d15b76 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -22,10 +22,10 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int if (isGlobal && runtime.state && !baseState) return runtime.state; const { timeZone, calendar } = getDateTimeFormat(); - const state = { + const state = (baseState ? Object.create(baseState) : { config: {}, parse: {} - } as t.Internal.State + }) as t.Internal.State; // 1. Establish the base parsing state state.parse = markConfig({ diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 9cd7dcb..f2eeb25 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -147,8 +147,10 @@ export class Tempo { return; // no local change needed // Use the correct alias engine: static for global, instance for local, and assign parentEngine for locals - const proto = Object.getPrototypeOf(shape); - const engine = shape.aliasEngine ??= new AliasEngine({ parent: proto.aliasEngine, logger: Tempo.#dbg }); + const parent = proto(shape); + const engine = Object.hasOwn(shape, 'aliasEngine') + ? shape.aliasEngine! + : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg })); engine.clear('event'); engine.registerEvents(events); @@ -195,8 +197,10 @@ export class Tempo { return; // no local change needed // Use the correct alias engine: static for global, instance for local - const proto = Object.getPrototypeOf(shape); - const engine = shape.aliasEngine ??= new AliasEngine({ parent: proto.aliasEngine, logger: Tempo.#dbg }); + const parent = proto(shape); + const engine = Object.hasOwn(shape, 'aliasEngine') + ? shape.aliasEngine! + : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg })); engine.clear('period'); engine.registerPeriods(periods); From a22e7c71dc2caddc9618a5b23379027c0fe77979 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 3 May 2026 20:05:21 +1000 Subject: [PATCH 06/29] hasOwn --- packages/tempo/src/tempo.class.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index f2eeb25..6ba4a1a 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -148,7 +148,7 @@ export class Tempo { // Use the correct alias engine: static for global, instance for local, and assign parentEngine for locals const parent = proto(shape); - const engine = Object.hasOwn(shape, 'aliasEngine') + const engine = hasOwn(shape, 'aliasEngine') ? shape.aliasEngine! : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg })); engine.clear('event'); @@ -198,7 +198,7 @@ export class Tempo { // Use the correct alias engine: static for global, instance for local const parent = proto(shape); - const engine = Object.hasOwn(shape, 'aliasEngine') + const engine = hasOwn(shape, 'aliasEngine') ? shape.aliasEngine! : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg })); engine.clear('period'); From fed3f71f2a9d037477359576a97c0e566f340a50 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 3 May 2026 20:09:51 +1000 Subject: [PATCH 07/29] all hasOwn --- packages/tempo/src/discrete/discrete.format.ts | 3 ++- packages/tempo/src/plugin/plugin.util.ts | 3 ++- packages/tempo/src/support/tempo.register.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/tempo/src/discrete/discrete.format.ts b/packages/tempo/src/discrete/discrete.format.ts index 21a145c..703aaac 100644 --- a/packages/tempo/src/discrete/discrete.format.ts +++ b/packages/tempo/src/discrete/discrete.format.ts @@ -7,6 +7,7 @@ import { delegator } from '#library/proxy.library.js'; import { isTempo, enums, Match, getRuntime, NumericPattern } from '#tempo/support'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Tempo } from '../tempo.class.js'; +import { hasOwn } from '#tempo/support/tempo.util.js'; declare module '../tempo.class.js' { interface Tempo { @@ -65,7 +66,7 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol if (!isZonedDateTime(zdt)) return ''; - let template = (isString(fmt) && formats && (typeof (formats as any).has === 'function' ? (formats as any).has(fmt as string) : Object.prototype.hasOwnProperty.call(formats, fmt as string))) + let template = (isString(fmt) && formats && (typeof (formats as any).has === 'function' ? (formats as any).has(fmt as string) : hasOwn(formats, fmt as string))) ? (formats as Record)[fmt as string] : String(fmt); diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 8148902..8302393 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -2,6 +2,7 @@ import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from import { secureRef } from '#library/proxy.library.js'; import { sym, getRuntime, isTempo } from '#tempo/support'; +import { hasOwn } from '#tempo/support/tempo.util.js'; import type { Tempo } from '../tempo.class.js'; import type { Plugin } from './plugin.type.js'; @@ -94,7 +95,7 @@ export const defineModule = (module: T): T => { */ export function attachStatics(TempoClass: any, props: Record) { for (const [key, val] of Object.entries(props)) { - if (Object.hasOwn(TempoClass, key)) { + if (hasOwn(TempoClass, key)) { const msg = `Static name collision on "${key}". Property is already defined on the host class.`; if (isFunction(TempoClass[sym.$logError])) { // use catch:true to report the collision without a fatal throw (supports re-extension in shared environments) diff --git a/packages/tempo/src/support/tempo.register.ts b/packages/tempo/src/support/tempo.register.ts index cb3113c..1bf410d 100644 --- a/packages/tempo/src/support/tempo.register.ts +++ b/packages/tempo/src/support/tempo.register.ts @@ -7,7 +7,7 @@ import { sym } from './tempo.symbol.js'; import type { Property } from '#library/type.library.js'; import { getRuntime } from './tempo.runtime.js'; -import { setProperty } from './tempo.util.js'; +import { hasOwn, setProperty } from './tempo.util.js'; // Import the live enums and their mutable state from the enum module import { STATE, REGISTRIES, DEFAULTS } from './tempo.enum.js'; @@ -50,7 +50,7 @@ export function registryReset() { Object.defineProperty(obj, key, desc); } else { // For non-extensible objects, only update if property exists - if (Object.prototype.hasOwnProperty.call(obj, key)) { + if (hasOwn(obj, key)) { Object.defineProperty(obj, key, desc); } else { console.warn(`[tempo] registryReset: Cannot define property '${String(key)}' on non-extensible object (property does not exist)`, obj); From ef10d0812890d19585a5e8de8f5531da6d36f2d5 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Sun, 3 May 2026 20:35:41 +1000 Subject: [PATCH 08/29] ci.yml --- .github/workflows/ci.yml | 46 +------------------ .../tempo/src/discrete/discrete.format.ts | 4 +- .../test/core/alias-engine-protochain.test.ts | 19 +++++++- 3 files changed, 20 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a84ed0..edc9095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,28 +40,6 @@ jobs: timeout-minutes: 30 if: github.ref == 'refs/heads/release-c-layout-order-planner' || github.event.pull_request.base.ref == 'main' steps: - - name: Print GitHub event context (for debug) - run: | - echo "GITHUB_REF: $GITHUB_REF" - echo "GITHUB_EVENT_NAME: $GITHUB_EVENT_NAME" - echo "GITHUB_HEAD_REF: $GITHUB_HEAD_REF" - echo "GITHUB_BASE_REF: $GITHUB_BASE_REF" - echo "github.ref: ${{ github.ref }}" - echo "github.event_name: ${{ github.event_name }}" - echo "github.head_ref: ${{ github.head_ref }}" - echo "github.base_ref: ${{ github.base_ref }}" - echo "github.event.pull_request.base.ref: ${{ github.event.pull_request.base.ref }}" - echo "github.sha: ${{ github.sha }}" - echo "github.repository: ${{ github.repository }}" - echo "github.actor: ${{ github.actor }}" - echo "github.workflow: ${{ github.workflow }}" - echo "github.run_id: ${{ github.run_id }}" - echo "github.run_number: ${{ github.run_number }}" - echo "github.job: ${{ github.job }}" - echo "github.ref_name: ${{ github.ref_name }}" - echo "github.ref_type: ${{ github.ref_type }}" - echo "github.event: $(cat $GITHUB_EVENT_PATH)" - shell: bash - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 @@ -91,26 +69,4 @@ jobs: run: | node -e "const r=require('./packages/tempo/bench-output.json');if(!r.success){console.error('Benchmark failed:',r.errors);process.exit(1)}else{console.log('Benchmark passed.')}" working-directory: ${{ github.workspace }} - - name: Install monorepo dependencies - run: npm ci - working-directory: ${{ github.workspace }} - - name: Run all tests with parse.preFilter - run: npm test - working-directory: packages/tempo - env: - TEMPO_PREFILTER_CI: 'true' - - name: Run end-to-end benchmark - run: npx tsx --conditions=development bench/bench.parse.prefilter.e2e.ts > bench-output.json 2> bench-error.log - working-directory: packages/tempo - - name: Upload benchmark output - if: always() - uses: actions/upload-artifact@v4 - with: - name: bench-parse-prefilter-e2e - path: | - packages/tempo/bench-output.json - packages/tempo/bench-error.log - - name: Validate benchmark output - run: | - node -e "const r=require('./packages/tempo/bench-output.json');if(!r.success){console.error('Benchmark failed:',r.errors);process.exit(1)}else{console.log('Benchmark passed.')}" - working-directory: ${{ github.workspace }} + diff --git a/packages/tempo/src/discrete/discrete.format.ts b/packages/tempo/src/discrete/discrete.format.ts index 703aaac..a2023ad 100644 --- a/packages/tempo/src/discrete/discrete.format.ts +++ b/packages/tempo/src/discrete/discrete.format.ts @@ -7,7 +7,7 @@ import { delegator } from '#library/proxy.library.js'; import { isTempo, enums, Match, getRuntime, NumericPattern } from '#tempo/support'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Tempo } from '../tempo.class.js'; -import { hasOwn } from '#tempo/support/tempo.util.js'; + declare module '../tempo.class.js' { interface Tempo { @@ -66,7 +66,7 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol if (!isZonedDateTime(zdt)) return ''; - let template = (isString(fmt) && formats && (typeof (formats as any).has === 'function' ? (formats as any).has(fmt as string) : hasOwn(formats, fmt as string))) + let template = (isString(fmt) && formats && (fmt as string in formats)) ? (formats as Record)[fmt as string] : String(fmt); diff --git a/packages/tempo/test/core/alias-engine-protochain.test.ts b/packages/tempo/test/core/alias-engine-protochain.test.ts index 1cf32a5..28bb4fa 100644 --- a/packages/tempo/test/core/alias-engine-protochain.test.ts +++ b/packages/tempo/test/core/alias-engine-protochain.test.ts @@ -1,8 +1,16 @@ import { AliasEngine } from '#tempo/engine/engine.alias.js'; import { Logify } from '#library/logify.class.js'; +import { vi } from 'vitest'; describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => { - const logger = new Logify({ debug: true }); + const logger = { + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + log: vi.fn(), + info: vi.fn(), + trace: vi.fn() + } as unknown as Logify; // Simulate a global state const globalShape = {} as { aliasEngine: AliasEngine }; @@ -38,11 +46,18 @@ describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => }); it('collision detection traverses the prototype chain', () => { + (logger.warn as any).mockClear(); + // Register a colliding alias in local localShape.aliasEngine.registerEventAlias('globalEvt', 'localShadow'); + // Should warn about collision with global - // (You may want to spy on logger.warn for a real assertion) + expect(logger.warn).toHaveBeenCalled(); + expect((logger.warn as any).mock.calls[0][0]).toMatch(/Collision detected/i); + expect(localShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('localShadow'); expect(globalShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + + (logger.warn as any).mockReset(); }); }); From 0e6a54689ccdc056b7e5422e641397d4aa5a7d4e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 4 May 2026 10:12:59 +1000 Subject: [PATCH 09/29] aliasEngine heirarchy --- packages/tempo/src/tempo.class.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 6ba4a1a..cb0441b 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1463,6 +1463,7 @@ export class Tempo { /** setup local 'config' and 'parse' rules (prototype-linked to global) */ #setLocal(options: t.Options = {}) { const classState = (this.constructor as any)[$Internal](); + this.#local = Object.create(classState); this.#local.config = markConfig(Object.create(classState.config)); Object.assign(this.#local.config, { scope: 'local' }); From 1d3e29eb03e751c6ddecf4f4293e35af74aa5d78 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 4 May 2026 10:19:59 +1000 Subject: [PATCH 10/29] Alias warning via Logify --- packages/tempo/src/engine/engine.alias.ts | 6 +++++- packages/tempo/src/tempo.class.ts | 18 ++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index cf5329d..d778dbd 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -4,21 +4,25 @@ import { asType } from '#library/type.library.js'; import type { Logify } from '#library/logify.class.js'; +import * as t from '../tempo.type.js'; export type AliasTarget = string | number | Function export interface AliasEngineOptions { parent?: AliasEngine | undefined; logger?: Logify | undefined; + config?: t.Internal.Config | undefined; } export class AliasEngine { #parentEngine?: AliasEngineOptions["parent"]; #logger?: AliasEngineOptions["logger"]; + #config?: AliasEngineOptions["config"]; constructor(options: AliasEngineOptions = {}) { this.#parentEngine = options.parent; this.#logger = options.logger; + this.#config = options.config; } /** @@ -141,7 +145,7 @@ export class AliasEngine { } if (collisionDetected && this.#logger) { - this.#logger.warn( + this.#logger.warn(this.#config, `[AliasEngine] Potential Collision detected for ${type} alias "${name}". Multiple definitions found. This may shadow or overwrite an existing alias.` ); } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index cb0441b..114655f 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -150,7 +150,7 @@ export class Tempo { const parent = proto(shape); const engine = hasOwn(shape, 'aliasEngine') ? shape.aliasEngine! - : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg })); + : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg, config: shape.config })); engine.clear('event'); engine.registerEvents(events); @@ -200,7 +200,7 @@ export class Tempo { const parent = proto(shape); const engine = hasOwn(shape, 'aliasEngine') ? shape.aliasEngine! - : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg })); + : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg, config: shape.config })); engine.clear('period'); engine.registerPeriods(periods); @@ -1093,7 +1093,7 @@ export class Tempo { /** allow for auto-convert of Tempo to BigInt, Number or String */ [Symbol.toPrimitive](hint?: 'string' | 'number' | 'default') { switch (hint) { - case 'string': return this.toString(); // ISO 8601 string + case 'string': return this.toString(); // iso 8601 string case 'number': return this.epoch.ms; // Unix epoch (milliseconds) default: return this.nano; // Unix epoch (nanoseconds) } @@ -1323,9 +1323,9 @@ export class Tempo { } /** 4-digit year (e.g., 2024) */ get yy() { return this.toDateTime().year } - /** 4-digit ISO week-numbering year */ get yw() { return this.toDateTime().yearOfWeek } + /** 4-digit iso week-numbering year */ get yw() { return this.toDateTime().yearOfWeek } /** Month number: Jan=1, Dec=12 */ get mm() { return this.toDateTime().month as t.mm } - /** ISO week number of the year */ get ww() { return this.toDateTime().weekOfYear as t.ww } + /** iso week number of the year */ get ww() { return this.toDateTime().weekOfYear as t.ww } /** Day of the month (1-31) */ get dd() { return this.toDateTime().day } /** Day of the month (alias for `dd`) */ get day() { return this.toDateTime().day } /** Hour of the day (0-23) */ get hh() { return this.toDateTime().hour as t.hh } @@ -1342,7 +1342,7 @@ export class Tempo { /** Full month name (e.g., 'January') */ get mon() { return Tempo.MONTHS.keyOf(this.toDateTime().month as t.Month) } /** Short weekday name (e.g., 'Mon') */ get www() { return Tempo.WEEKDAY.keyOf(this.toDateTime().dayOfWeek as t.Weekday) } /** Full weekday name (e.g., 'Monday') */ get wkd() { return Tempo.WEEKDAYS.keyOf(this.toDateTime().dayOfWeek as t.Weekday) } - /** ISO weekday number: Mon=1, Sun=7 */ get dow() { return this.toDateTime().dayOfWeek as t.Weekday } + /** iso weekday number: Mon=1, Sun=7 */ get dow() { return this.toDateTime().dayOfWeek as t.Weekday } /** Nanoseconds since Unix epoch (BigInt) */ get nano() { return this.toDateTime().epochNanoseconds } /** `true` if the underlying date-time is valid. */ get isValid() { return this.#resolve(zdt => !this.#errored && isZonedDateTime(zdt)); } @@ -1450,16 +1450,14 @@ export class Tempo { /** the current system time localized to this instance. */toNow() { return instant().toZonedDateTimeISO(this.tz).withCalendar(this.cal) } /** the date-time as a standard `Date` object. */ toDate() { return new Date(this.toDateTime().round({ smallestUnit: enums.ELEMENT.ms }).epochMilliseconds) } - /**ISO8601 string representation of the date-time. */ + /** Custom JSON serialization for `JSON.stringify`. */ toJSON() { return { ...this.#local.config, value: this.toString() } } + /** iso8601 string representation of the date-time. */ toString() { return (this.isValid && !this.#errored) ? this.toPlainDateTime().toString({ calendarName: 'never' }) : String(this.#tempo ?? ''); } - /** Custom JSON serialization for `JSON.stringify`. */ - toJSON() { return { ...this.#local.config, value: this.toString() } } - /** setup local 'config' and 'parse' rules (prototype-linked to global) */ #setLocal(options: t.Options = {}) { const classState = (this.constructor as any)[$Internal](); From 1e60a99b9a10fee17684d5252515757a8eee13f7 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 4 May 2026 18:14:38 +1000 Subject: [PATCH 11/29] extending AliasEngine --- packages/tempo/plan/alias.registration.md | 68 ++++++++++ packages/tempo/src/engine/engine.alias.ts | 146 ++++++++++++++++------ packages/tempo/src/support/tempo.util.ts | 3 - packages/tempo/test/engine.alias.test.ts | 47 +++++++ 4 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 packages/tempo/plan/alias.registration.md create mode 100644 packages/tempo/test/engine.alias.test.ts diff --git a/packages/tempo/plan/alias.registration.md b/packages/tempo/plan/alias.registration.md new file mode 100644 index 0000000..5f256fa --- /dev/null +++ b/packages/tempo/plan/alias.registration.md @@ -0,0 +1,68 @@ +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/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index d778dbd..33c8752 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -7,7 +7,6 @@ import type { Logify } from '#library/logify.class.js'; import * as t from '../tempo.type.js'; export type AliasTarget = string | number | Function - export interface AliasEngineOptions { parent?: AliasEngine | undefined; logger?: Logify | undefined; @@ -18,51 +17,120 @@ export class AliasEngine { #parentEngine?: AliasEngineOptions["parent"]; #logger?: AliasEngineOptions["logger"]; #config?: AliasEngineOptions["config"]; + #scopePrefix: string; + #eventAliasCount: number = 0; + #periodAliasCount: number = 0; + + #eventMap: Map = new Map(); + #periodMap: Map = new Map(); + #eventCollisions: Map = new Map(); + #periodCollisions: Map = new Map(); + + #aliasRegistry: Array<{ + type: 'event' | 'period', + name: string, + baseWord: string, + target: AliasTarget, + index: number, + prefix: string, + groupName: string + }> = []; constructor(options: AliasEngineOptions = {}) { this.#parentEngine = options.parent; this.#logger = options.logger; this.#config = options.config; + if (!this.#parentEngine) { + this.#scopePrefix = '0'; + } else { + this.#scopePrefix = `${Number(this.#parentEngine.#scopePrefix) + 1}`; + } } - /** - * Detect likely overlap between two alias keys/patterns (moved from Tempo) - */ static isAliasCollision(a: string, b: string): boolean { const left = a.trim().toLowerCase(); const right = b.trim().toLowerCase(); - if (!left || !right) return false; if (left === right) return true; - - // Extract the 'core' characters to determine if they conceptually target the same word - const getBaseWord = (s: string) => s - .replace(/\[[^\]]*\]\?/g, '') // remove optional character classes (e.g. [ -]?) - .replace(/.\?/g, '') // remove optional single characters (e.g. s?) - .replace(/[^a-z0-9]/g, ''); // remove all non-alphanumeric characters (regex metachars, spaces, hyphens) - - const baseLeft = getBaseWord(left); - const baseRight = getBaseWord(right); - + const baseLeft = AliasEngine.getBaseWord(left); + const baseRight = AliasEngine.getBaseWord(right); if (!baseLeft || !baseRight) return false; - return baseLeft === baseRight; } - #eventMap: Map = new Map(); - #periodMap: Map = new Map(); - #eventCollisions: Map = new Map(); - #periodCollisions: Map = new Map(); + static getBaseWord(s: string): string { + return s + .replace(/\[[^\]]*\]\?/g, '') + .replace(/.\?/g, '') + .replace(/[^a-z0-9]/g, ''); + } - // Event alias management registerEventAlias(name: string, target: AliasTarget): void { this.#registerAliasWithCollision(name, target, this.#eventMap, this.#eventCollisions, 'event'); + const index = this.#eventAliasCount++; + const prefix = this.#scopePrefix; + const groupName = `${prefix}evt${index}`; + const baseWord = AliasEngine.getBaseWord(name); + this.#aliasRegistry.push({ type: 'event', name, baseWord, target, index, prefix, groupName }); } - registerEvents(events: [string, AliasTarget][]): void { + /** + * Register event aliases and return a regex string representing the full lineage of event aliases up the proto chain. + * Ensures that shadowed/collided baseNames are excluded from parent levels. + */ + registerEvents(events: [string, AliasTarget][]): string { for (const [name, target] of events) this.registerEventAlias(name, target); + const patterns: string[] = []; + const seenBaseNames = new Set(); + let engine: AliasEngine | undefined = this; + while (engine) { + const localGroups = engine.#aliasRegistry + .filter(a => a.type === 'event' && !seenBaseNames.has(a.baseWord)) + .map(a => { + seenBaseNames.add(a.baseWord); + return `(?<${a.groupName}>${a.name})`; + }) + .join('|'); + if (localGroups) patterns.push(`(${localGroups})`); + engine = engine.#parentEngine; + } + return patterns.join('|'); } + + registerPeriodAlias(name: string, target: AliasTarget): void { + this.#registerAliasWithCollision(name, target, this.#periodMap, this.#periodCollisions, 'period'); + const index = this.#periodAliasCount++; + const prefix = this.#scopePrefix; + const groupName = `${prefix}per${index}`; + const baseWord = AliasEngine.getBaseWord(name); + this.#aliasRegistry.push({ type: 'period', name, baseWord, target, index, prefix, groupName }); + } + + /** + * Register period aliases and return a regex string representing the full lineage of period aliases up the proto chain. + * Ensures that shadowed/collided baseNames are excluded from parent levels. + */ + registerPeriods(periods: [string, AliasTarget][]): string { + for (const [name, target] of periods) + this.registerPeriodAlias(name, target); + const patterns: string[] = []; + const seenBaseNames = new Set(); + let engine: AliasEngine | undefined = this; + while (engine) { + const localGroups = engine.#aliasRegistry + .filter(a => a.type === 'period' && !seenBaseNames.has(a.baseWord)) + .map(a => { + seenBaseNames.add(a.baseWord); + return `(?<${a.groupName}>${a.name})`; + }) + .join('|'); + if (localGroups) patterns.push(`(${localGroups})`); + engine = engine.#parentEngine; + } + return patterns.join('|'); + } + resolveEventAlias(name: string, thisArg?: any) { return this.#resolveAlias(name, this.#eventMap, thisArg); } @@ -76,15 +144,6 @@ export class AliasEngine { return Object.fromEntries(this.#eventCollisions.entries()); } - // Period alias management - registerPeriodAlias(name: string, target: AliasTarget): void { - this.#registerAliasWithCollision(name, target, this.#periodMap, this.#periodCollisions, 'period'); - } - - registerPeriods(periods: [string, AliasTarget][]): void { - for (const [name, target] of periods) - this.registerPeriodAlias(name, target); - } resolvePeriodAlias(name: string, thisArg?: any) { return this.#resolveAlias(name, this.#periodMap, thisArg); } @@ -98,7 +157,6 @@ export class AliasEngine { return Object.fromEntries(this.#periodCollisions.entries()); } - // Shared logic #registerAliasWithCollision( name: string, target: AliasTarget, @@ -125,7 +183,7 @@ export class AliasEngine { // Check for parent collisions using isAliasCollision let parent = this.#parentEngine; while (parent) { - const parentAliases = type === 'event' ? parent.getAllEventAliases() : parent.getAllPeriodAliases(); + const parentAliases = type === 'event' ? parent.getAllEventAliases() : (parent as AliasEngine).getAllPeriodAliases(); for (const [parentName, parentTarget] of Object.entries(parentAliases)) { if ( parentTarget !== target && @@ -139,8 +197,6 @@ export class AliasEngine { collisionDetected = true; } } - // Access parent's parent engine via a public way if possible, or keep it private if safe - // Since we're in the same class, we can still use #parentEngine for the chain parent = parent.#parentEngine; } @@ -162,18 +218,15 @@ export class AliasEngine { switch (type) { case 'Function': return value.call(thisArg); - case 'String': case 'Number': return value; - default: currentEngine = currentEngine.#parentEngine; if (currentEngine) map = isEvent ? currentEngine.#eventMap : currentEngine.#periodMap; } } - return name; } @@ -181,10 +234,27 @@ export class AliasEngine { if (!type || type === 'event') { this.#eventMap.clear(); this.#eventCollisions.clear(); + this.#aliasRegistry = this.#aliasRegistry.filter(a => a.type !== 'event'); + this.#eventAliasCount = 0; } if (!type || type === 'period') { this.#periodMap.clear(); this.#periodCollisions.clear(); + this.#aliasRegistry = this.#aliasRegistry.filter(a => a.type !== 'period'); + this.#periodAliasCount = 0; } } + + getIndexedAliases(type: 'event' | 'period') { + return this.#aliasRegistry + .filter(a => a.type === type) + .map(a => ({ + name: a.name, + target: a.target, + index: a.index, + prefix: a.prefix, + groupName: a.groupName + })); + } } + diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts index d0aa5d4..1c4acb5 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -148,8 +148,6 @@ export function compileRegExp(layout: string | RegExp, state: t.Internal.State, } } - - /** @internal build RegExp patterns into the state */ export function setPatterns(state: t.Internal.State) { // ensure we have our own isolated mutable containers before mutation @@ -189,7 +187,6 @@ export function setPatterns(state: t.Internal.State) { state.parse.pattern.set(symbol, compiled); }); - } /** diff --git a/packages/tempo/test/engine.alias.test.ts b/packages/tempo/test/engine.alias.test.ts new file mode 100644 index 0000000..84e0d75 --- /dev/null +++ b/packages/tempo/test/engine.alias.test.ts @@ -0,0 +1,47 @@ +import { AliasEngine } from '../src/engine/engine.alias.js'; + +describe('AliasEngine', () => { + it('assigns correct prefixes and group names for root and children', () => { + const root = new AliasEngine(); + const child1 = new AliasEngine({ parent: root }); + const child2 = new AliasEngine({ parent: root }); + root.registerEventAlias('rootEvent', 'rootValue'); + child1.registerEventAlias('child1Event', 'child1Value'); + child2.registerEventAlias('child2Event', 'child2Value'); + expect(root.getIndexedAliases('event')[0].groupName).toBe('0evt0'); + expect(child1.getIndexedAliases('event')[0].groupName).toBe('1evt0'); + expect(child2.getIndexedAliases('event')[0].groupName).toBe('1evt0'); + }); + + it('returns correct lineage from registerEvents/registerPeriods', () => { + const root = new AliasEngine(); + const events = [ ['a', 'A'], ['b', 'B'] ] as [string, string][]; + const periods = [ ['x', 'X'], ['y', 'Y'] ] as [string, string][]; + const eventLineage = root.registerEvents(events); + const periodLineage = root.registerPeriods(periods); + expect(eventLineage[0].groupName).toBe('0evt0'); + expect(eventLineage[1].groupName).toBe('0evt1'); + expect(periodLineage[0].groupName).toBe('0per0'); + expect(periodLineage[1].groupName).toBe('0per1'); + }); + + it('resolves aliases up the proto chain', () => { + const root = new AliasEngine(); + const child = new AliasEngine({ parent: root }); + root.registerEventAlias('rootEvent', 'rootValue'); + child.registerEventAlias('childEvent', 'childValue'); + expect(child.resolveEventAlias('rootEvent')).toBe('rootValue'); + expect(child.resolveEventAlias('childEvent')).toBe('childValue'); + }); + + it('clears aliases correctly', () => { + const root = new AliasEngine(); + root.registerEventAlias('e1', 'v1'); + root.registerPeriodAlias('p1', 'v2'); + root.clear('event'); + expect(root.getIndexedAliases('event').length).toBe(0); + expect(root.getIndexedAliases('period').length).toBe(1); + root.clear('period'); + expect(root.getIndexedAliases('period').length).toBe(0); + }); +}); From e83c8d29c1e610ba319e6bfea1d177309d7c18fe Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Mon, 4 May 2026 18:22:27 +1000 Subject: [PATCH 12/29] monday 6:22 --- packages/tempo/src/engine/engine.alias.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 33c8752..921125d 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -114,9 +114,11 @@ export class AliasEngine { registerPeriods(periods: [string, AliasTarget][]): string { for (const [name, target] of periods) this.registerPeriodAlias(name, target); + const patterns: string[] = []; const seenBaseNames = new Set(); let engine: AliasEngine | undefined = this; + while (engine) { const localGroups = engine.#aliasRegistry .filter(a => a.type === 'period' && !seenBaseNames.has(a.baseWord)) @@ -128,6 +130,7 @@ export class AliasEngine { if (localGroups) patterns.push(`(${localGroups})`); engine = engine.#parentEngine; } + return patterns.join('|'); } @@ -215,18 +218,22 @@ export class AliasEngine { while (currentEngine) { const { type, value } = asType(map.get(name)); + switch (type) { case 'Function': return value.call(thisArg); + case 'String': case 'Number': return value; + default: currentEngine = currentEngine.#parentEngine; if (currentEngine) map = isEvent ? currentEngine.#eventMap : currentEngine.#periodMap; } } + return name; } From 74ae34b18512b9a3bfee145f814e586c357ec3fd Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 5 May 2026 10:23:43 +1000 Subject: [PATCH 13/29] my AliasEngine --- packages/tempo/src/engine/engine.alias.ts | 318 ++++++++-------------- packages/tempo/src/tempo.class.ts | 22 +- packages/tempo/test/engine.alias.test.ts | 51 ++-- 3 files changed, 149 insertions(+), 242 deletions(-) diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 921125d..b42f590 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -2,266 +2,170 @@ // Alias Resolution Engine for Tempo // Responsible for event/period alias mapping, collision detection, and snippet rebinding -import { asType } from '#library/type.library.js'; +/** + * AliasEngine Collision Policy: + * + * If an Event and a Period alias share the same base word (e.g., 'xmas'), + * a warning is logged and only the Event alias is included in the regex pattern for parsing. + * This ensures deterministic parsing and avoids ambiguous matches. + * + * Both aliases may still be registered and resolved, but only the Event alias will be matched + * during parsing if a collision occurs. This is a best-effort approach and not entirely risk-free; + * users should avoid such collisions when possible. + */ + +import type { Nullable } from '#library/type.library.js'; import type { Logify } from '#library/logify.class.js'; import * as t from '../tempo.type.js'; +import { isFunction } from '#library'; +import { ownEntries } from '#library/primitive.library.js'; export type AliasTarget = string | number | Function +type AliasType = 'evt' | 'per'; +type AliasKey = `${number}${AliasType}${number}`; +type State = Record + export interface AliasEngineOptions { - parent?: AliasEngine | undefined; - logger?: Logify | undefined; - config?: t.Internal.Config | undefined; + parent?: Nullable; + logger?: Nullable; + config?: Nullable; +} +interface Registry { // information about each registered alias + key?: AliasKey; + name: string; + target: AliasTarget; + type: AliasType; + baseWord: string; + collision?: boolean; } export class AliasEngine { - #parentEngine?: AliasEngineOptions["parent"]; + #parent?: AliasEngineOptions["parent"]; #logger?: AliasEngineOptions["logger"]; #config?: AliasEngineOptions["config"]; - #scopePrefix: string; - #eventAliasCount: number = 0; - #periodAliasCount: number = 0; - - #eventMap: Map = new Map(); - #periodMap: Map = new Map(); - #eventCollisions: Map = new Map(); - #periodCollisions: Map = new Map(); - #aliasRegistry: Array<{ - type: 'event' | 'period', - name: string, - baseWord: string, - target: AliasTarget, - index: number, - prefix: string, - groupName: string - }> = []; + #depth: number; // the depth of this engine in the proto chain + #count: Record; // count of aliases registered at this level (used for indexing) + #state: State; // object that holds alias mappings, collisions, and registry for this engine + #words: Record; // object of base words for collision detection - constructor(options: AliasEngineOptions = {}) { - this.#parentEngine = options.parent; + constructor(options = {} as AliasEngineOptions) { + this.#parent = options.parent ?? null; this.#logger = options.logger; this.#config = options.config; - if (!this.#parentEngine) { - this.#scopePrefix = '0'; + + if (this.#parent) { + if (!(this.#parent instanceof AliasEngine)) + this.#logger?.error(this.#config, "Parent engine must be an instance of 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 } else { - this.#scopePrefix = `${Number(this.#parentEngine.#scopePrefix) + 1}`; + 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) } - } - static isAliasCollision(a: string, b: string): boolean { - const left = a.trim().toLowerCase(); - const right = b.trim().toLowerCase(); - if (!left || !right) return false; - if (left === right) return true; - const baseLeft = AliasEngine.getBaseWord(left); - const baseRight = AliasEngine.getBaseWord(right); - if (!baseLeft || !baseRight) return false; - return baseLeft === baseRight; + this.#count = { evt: 0, per: 0 }; } - static getBaseWord(s: string): string { + static #getBaseWord(s: string): string { return s .replace(/\[[^\]]*\]\?/g, '') .replace(/.\?/g, '') .replace(/[^a-z0-9]/g, ''); } - registerEventAlias(name: string, target: AliasTarget): void { - this.#registerAliasWithCollision(name, target, this.#eventMap, this.#eventCollisions, 'event'); - const index = this.#eventAliasCount++; - const prefix = this.#scopePrefix; - const groupName = `${prefix}evt${index}`; - const baseWord = AliasEngine.getBaseWord(name); - this.#aliasRegistry.push({ type: 'event', name, baseWord, target, index, prefix, groupName }); - } - /** - * Register event aliases and return a regex string representing the full lineage of event aliases up the proto chain. - * Ensures that shadowed/collided baseNames are excluded from parent levels. + * Register aliases and return a regex string representing the full lineage of aliases up the proto chain. + * Ensures that shadowed/collided baseNames are excluded from parent levels. + * Note that we track collisions across both Event and Period aliases in the same #words object, since they can + * collide with each other (e.g. "on" could be an event alias and a period alias, + * which would cause confusion and unintended matches). */ - registerEvents(events: [string, AliasTarget][]): string { - for (const [name, target] of events) - this.registerEventAlias(name, target); - const patterns: string[] = []; - const seenBaseNames = new Set(); - let engine: AliasEngine | undefined = this; - while (engine) { - const localGroups = engine.#aliasRegistry - .filter(a => a.type === 'event' && !seenBaseNames.has(a.baseWord)) - .map(a => { - seenBaseNames.add(a.baseWord); - return `(?<${a.groupName}>${a.name})`; - }) - .join('|'); - if (localGroups) patterns.push(`(${localGroups})`); - engine = engine.#parentEngine; + registerAliases(type: AliasType, events: [string, AliasTarget][]) { + for (const [name, target] of events) { + const aliasKey = `${this.#depth}${type}${this.#count[type]++}` as AliasKey; + const baseWord = AliasEngine.#getBaseWord(name); + const collision = baseWord in this.#words; // check for collision with existing base words in this engine and parent engines + + if (collision && this.#logger) + this.#logger.warn(this.#config, + `[AliasEngine] Collision detected for ${type} alias "${name}". This may overwrite an existing alias.` + ); + + this.#words[baseWord] = name; // track the base word for collision detection + this.#state[aliasKey] = { + name, // plain string or regex-like string + target, // string, number, or function + type, // 'evt' or 'per' + baseWord, // used for collision detection + collision, // needed ? + } } - return patterns.join('|'); - } - registerPeriodAlias(name: string, target: AliasTarget): void { - this.#registerAliasWithCollision(name, target, this.#periodMap, this.#periodCollisions, 'period'); - const index = this.#periodAliasCount++; - const prefix = this.#scopePrefix; - const groupName = `${prefix}per${index}`; - const baseWord = AliasEngine.getBaseWord(name); - this.#aliasRegistry.push({ type: 'period', name, baseWord, target, index, prefix, groupName }); + return this.getPatterns(type); } /** - * Register period aliases and return a regex string representing the full lineage of period aliases up the proto chain. - * Ensures that shadowed/collided baseNames are excluded from parent levels. + * Build regex patterns for this engine and all parent engines, excluding shadowed/collided baseNames. + * This ensures that if an alias is shadowed by a child engine, + * it won't be included in the regex patterns of the parent engine, + * preventing unintended matches and preserving the expected behavior of alias resolution. */ - registerPeriods(periods: [string, AliasTarget][]): string { - for (const [name, target] of periods) - this.registerPeriodAlias(name, target); - + getPatterns(type: AliasType): string { const patterns: string[] = []; const seenBaseNames = new Set(); - let engine: AliasEngine | undefined = this; - while (engine) { - const localGroups = engine.#aliasRegistry - .filter(a => a.type === 'period' && !seenBaseNames.has(a.baseWord)) - .map(a => { - seenBaseNames.add(a.baseWord); - return `(?<${a.groupName}>${a.name})`; - }) - .join('|'); - if (localGroups) patterns.push(`(${localGroups})`); - engine = engine.#parentEngine; + for (const alias in this.#state) { + const register = this.#state[alias as AliasKey]; + + if (!seenBaseNames.has(register.baseWord)) { + seenBaseNames.add(register.baseWord); + + if (register.type === type) + patterns.push(`(?<${alias}>${register.name})`); + } } return patterns.join('|'); } - resolveEventAlias(name: string, thisArg?: any) { - return this.#resolveAlias(name, this.#eventMap, thisArg); - } - hasEventAlias(name: string): boolean { - return this.#eventMap.has(name); - } - getAllEventAliases(): Record { - return Object.fromEntries(this.#eventMap.entries()); - } - detectEventCollisions(): Record { - return Object.fromEntries(this.#eventCollisions.entries()); - } - - resolvePeriodAlias(name: string, thisArg?: any) { - return this.#resolveAlias(name, this.#periodMap, thisArg); - } - hasPeriodAlias(name: string): boolean { - return this.#periodMap.has(name); - } - getAllPeriodAliases(): Record { - return Object.fromEntries(this.#periodMap.entries()); - } - detectPeriodCollisions(): Record { - return Object.fromEntries(this.#periodCollisions.entries()); + hasAlias(name: AliasKey) { + return name in this.#state; } - #registerAliasWithCollision( - name: string, - target: AliasTarget, - map: Map, - collisions: Map, - type: 'event' | 'period' - ) { - let collisionDetected = false; - // Check for local collisions using isAliasCollision - for (const [existingName, existingTarget] of map.entries()) { - if ( - existingTarget !== target && - AliasEngine.isAliasCollision(existingName, name) - ) { - const existing = collisions.get(existingName) || []; - collisions.set( - existingName, - Array.from(new Set([...existing, target, existingTarget])) - ); - collisionDetected = true; - } - } + resolveAlias(name: AliasKey, thisArg?: any) { + const register = this.#state[name]; + if (!register) return name; - // Check for parent collisions using isAliasCollision - let parent = this.#parentEngine; - while (parent) { - const parentAliases = type === 'event' ? parent.getAllEventAliases() : (parent as AliasEngine).getAllPeriodAliases(); - for (const [parentName, parentTarget] of Object.entries(parentAliases)) { - if ( - parentTarget !== target && - AliasEngine.isAliasCollision(parentName, name) - ) { - const parentCollisions = collisions.get(parentName) || []; - collisions.set( - parentName, - Array.from(new Set([...parentCollisions, target, parentTarget])) - ); - collisionDetected = true; - } - } - parent = parent.#parentEngine; - } - - if (collisionDetected && this.#logger) { - this.#logger.warn(this.#config, - `[AliasEngine] Potential Collision detected for ${type} alias "${name}". Multiple definitions found. This may shadow or overwrite an existing alias.` - ); - } - - map.set(name, target); + return isFunction(register.target) + ? register.target.call(thisArg) + : register.target; } - #resolveAlias(name: string, map: Map, thisArg?: any): string | number { - let currentEngine: AliasEngine | undefined = this; - const isEvent = map === this.#eventMap; + getAliases(type?: AliasType) { + const aliases = [] as Registry[]; - while (currentEngine) { - const { type, value } = asType(map.get(name)); + ownEntries(this.#state) // just the entries at this depth + .filter(([_, register]) => !type || register.type === type) + .forEach(([key, register]) => { + aliases.push(Object.assign({}, { key }, register)); + }); - switch (type) { - case 'Function': - return value.call(thisArg); + return aliases; + } - case 'String': - case 'Number': - return value; + clear(type: AliasType) { + this.#count[type] = 0; - default: - currentEngine = currentEngine.#parentEngine; - if (currentEngine) - map = isEvent ? currentEngine.#eventMap : currentEngine.#periodMap; + for (const registry in this.#state) { + if (this.#state[registry as AliasKey].type === type) { + delete this.#state[registry as AliasKey]; } } - - return name; - } - - clear(type?: 'event' | 'period'): void { - if (!type || type === 'event') { - this.#eventMap.clear(); - this.#eventCollisions.clear(); - this.#aliasRegistry = this.#aliasRegistry.filter(a => a.type !== 'event'); - this.#eventAliasCount = 0; - } - if (!type || type === 'period') { - this.#periodMap.clear(); - this.#periodCollisions.clear(); - this.#aliasRegistry = this.#aliasRegistry.filter(a => a.type !== 'period'); - this.#periodAliasCount = 0; - } } - getIndexedAliases(type: 'event' | 'period') { - return this.#aliasRegistry - .filter(a => a.type === type) - .map(a => ({ - name: a.name, - target: a.target, - index: a.index, - prefix: a.prefix, - groupName: a.groupName - })); - } } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 114655f..6c60273 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -151,13 +151,12 @@ export class Tempo { const engine = hasOwn(shape, 'aliasEngine') ? shape.aliasEngine! : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg, config: shape.config })); - engine.clear('event'); - engine.registerEvents(events); + const groups = engine.registerAliases('evt', events); - const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') - const groups = events - .map(([pat, _], idx) => `(?<${src}evt${idx}>${pat})`) // assign a number to the pattern - .join('|') // make an 'Or' pattern for the event-keys + // const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') + // const groups = events + // .map(([pat, _], idx) => `(?<${src}evt${idx}>${pat})`) // assign a number to the pattern + // .join('|') // make an 'Or' pattern for the event-keys if (groups) { const protoEvt = proto(shape.parse.snippet)[Token.evt]?.source; @@ -201,13 +200,12 @@ export class Tempo { const engine = hasOwn(shape, 'aliasEngine') ? shape.aliasEngine! : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg, config: shape.config })); - engine.clear('period'); - engine.registerPeriods(periods); + const groups = engine.registerAliases('per', periods); - const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') - const groups = periods - .map(([pat, _], idx) => `(?<${src}per${idx}>${pat})`) // {pattern} is the 1st element of the tuple - .join('|') // make an 'or' pattern for the period-keys + // const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') + // const groups = periods + // .map(([pat, _], idx) => `(?<${src}per${idx}>${pat})`) // {pattern} is the 1st element of the tuple + // .join('|') // make an 'or' pattern for the period-keys if (groups) { const protoPer = proto(shape.parse.snippet)[Token.per]?.source; diff --git a/packages/tempo/test/engine.alias.test.ts b/packages/tempo/test/engine.alias.test.ts index 84e0d75..9f370bc 100644 --- a/packages/tempo/test/engine.alias.test.ts +++ b/packages/tempo/test/engine.alias.test.ts @@ -5,43 +5,48 @@ describe('AliasEngine', () => { const root = new AliasEngine(); const child1 = new AliasEngine({ parent: root }); const child2 = new AliasEngine({ parent: root }); - root.registerEventAlias('rootEvent', 'rootValue'); - child1.registerEventAlias('child1Event', 'child1Value'); - child2.registerEventAlias('child2Event', 'child2Value'); - expect(root.getIndexedAliases('event')[0].groupName).toBe('0evt0'); - expect(child1.getIndexedAliases('event')[0].groupName).toBe('1evt0'); - expect(child2.getIndexedAliases('event')[0].groupName).toBe('1evt0'); + root.registerAliases('evt', [ ['rootEvent', 'rootValue'] ]); + child1.registerAliases('evt', [ ['child1Event', 'child1Value'] ]); + child2.registerAliases('evt', [ ['child2Event', 'child2Value'] ]); + expect(root.getAliases('evt')[0].key).toBe('0evt0'); + expect(child1.getAliases('evt')[0].key).toBe('1evt0'); + expect(child2.getAliases('evt')[0].key).toBe('1evt0'); }); it('returns correct lineage from registerEvents/registerPeriods', () => { const root = new AliasEngine(); const events = [ ['a', 'A'], ['b', 'B'] ] as [string, string][]; const periods = [ ['x', 'X'], ['y', 'Y'] ] as [string, string][]; - const eventLineage = root.registerEvents(events); - const periodLineage = root.registerPeriods(periods); - expect(eventLineage[0].groupName).toBe('0evt0'); - expect(eventLineage[1].groupName).toBe('0evt1'); - expect(periodLineage[0].groupName).toBe('0per0'); - expect(periodLineage[1].groupName).toBe('0per1'); + const eventPattern = root.registerAliases('evt', events); + const periodPattern = root.registerAliases('per', periods); + const eventLineage = root.getAliases('evt'); + const periodLineage = root.getAliases('per'); + expect(eventPattern).toBe('(?<0evt0>a)|(?<0evt1>b)'); + expect(periodPattern).toBe('(?<0per0>x)|(?<0per1>y)'); + + expect(eventLineage[0].key).toBe('0evt0'); + expect(eventLineage[1].key).toBe('0evt1'); + expect(periodLineage[0].key).toBe('0per0'); + expect(periodLineage[1].key).toBe('0per1'); }); it('resolves aliases up the proto chain', () => { const root = new AliasEngine(); const child = new AliasEngine({ parent: root }); - root.registerEventAlias('rootEvent', 'rootValue'); - child.registerEventAlias('childEvent', 'childValue'); - expect(child.resolveEventAlias('rootEvent')).toBe('rootValue'); - expect(child.resolveEventAlias('childEvent')).toBe('childValue'); + root.registerAliases('evt', [ ['rootEvent', 'rootValue'] ]); + child.registerAliases('evt', [ ['childEvent', 'childValue'] ]); + expect(child.resolveAlias('0evt0')).toBe('rootValue'); + expect(child.resolveAlias('1evt0')).toBe('childValue'); }); it('clears aliases correctly', () => { const root = new AliasEngine(); - root.registerEventAlias('e1', 'v1'); - root.registerPeriodAlias('p1', 'v2'); - root.clear('event'); - expect(root.getIndexedAliases('event').length).toBe(0); - expect(root.getIndexedAliases('period').length).toBe(1); - root.clear('period'); - expect(root.getIndexedAliases('period').length).toBe(0); + root.registerAliases('evt', [ ['e1', 'v1'] ]); + root.registerAliases('per', [ ['p1', 'v2'] ]); + root.clear('evt'); + expect(root.getAliases('evt').length).toBe(0); + expect(root.getAliases('per').length).toBe(1); + root.clear('per'); + expect(root.getAliases('per').length).toBe(0); }); }); From 8194de7583850ff918c4958856582bb735fd721c Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 5 May 2026 13:21:10 +1000 Subject: [PATCH 14/29] new capture-group name syntax --- packages/tempo/src/engine/engine.alias.ts | 6 +- .../test/core/alias-engine-protochain.test.ts | 32 ++--- .../alias-engine.mock.test.ts} | 24 ++-- packages/tempo/test/core/alias-engine.test.ts | 115 ++++++++++-------- 4 files changed, 97 insertions(+), 80 deletions(-) rename packages/tempo/test/{engine.alias.test.ts => core/alias-engine.mock.test.ts} (70%) diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index b42f590..52df38a 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -16,13 +16,13 @@ import type { Nullable } from '#library/type.library.js'; import type { Logify } from '#library/logify.class.js'; -import * as t from '../tempo.type.js'; import { isFunction } from '#library'; import { ownEntries } from '#library/primitive.library.js'; +import * as t from '../tempo.type.js'; export type AliasTarget = string | number | Function type AliasType = 'evt' | 'per'; -type AliasKey = `${number}${AliasType}${number}`; + type AliasKey = `${AliasType}${number}_${number}`; type State = Record export interface AliasEngineOptions { @@ -86,7 +86,7 @@ export class AliasEngine { */ registerAliases(type: AliasType, events: [string, AliasTarget][]) { for (const [name, target] of events) { - const aliasKey = `${this.#depth}${type}${this.#count[type]++}` as AliasKey; + const aliasKey = `${type}${this.#depth}_${this.#count[type]++}` as AliasKey; const baseWord = AliasEngine.#getBaseWord(name); const collision = baseWord in this.#words; // check for collision with existing base words in this engine and parent engines diff --git a/packages/tempo/test/core/alias-engine-protochain.test.ts b/packages/tempo/test/core/alias-engine-protochain.test.ts index 28bb4fa..d21bb69 100644 --- a/packages/tempo/test/core/alias-engine-protochain.test.ts +++ b/packages/tempo/test/core/alias-engine-protochain.test.ts @@ -15,48 +15,48 @@ describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => // Simulate a global state const globalShape = {} as { aliasEngine: AliasEngine }; globalShape.aliasEngine = new AliasEngine({ logger }); - globalShape.aliasEngine.registerEventAlias('globalEvt', 'globalValue'); + globalShape.aliasEngine.registerAliases('evt', [ ['globalEvt', 'globalValue'] ]); // Simulate a sandbox state inheriting from global const sandboxShape = Object.create(globalShape); sandboxShape.aliasEngine = new AliasEngine({ parent: globalShape.aliasEngine, logger }); - sandboxShape.aliasEngine.registerEventAlias('sandboxEvt', 'sandboxValue'); + sandboxShape.aliasEngine.registerAliases('evt', [ ['sandboxEvt', 'sandboxValue'] ]); // Simulate a local/instance state inheriting from sandbox const localShape = Object.create(sandboxShape); localShape.aliasEngine = new AliasEngine({ parent: sandboxShape.aliasEngine, logger }); - localShape.aliasEngine.registerEventAlias('localEvt', 'localValue'); + localShape.aliasEngine.registerAliases('evt', [ ['localEvt', 'localValue'] ]); it('resolves local, sandbox, and global aliases in correct order', () => { // Local should resolve its own - expect(localShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localValue'); + expect(localShape.aliasEngine.resolveAlias('evt_2_0')).toBe('localValue'); // Local should resolve sandbox - expect(localShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxValue'); + expect(localShape.aliasEngine.resolveAlias('evt1_0')).toBe('sandboxValue'); // Local should resolve global - expect(localShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + expect(localShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); // Sandbox should not see local - expect(sandboxShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localEvt'); + expect(sandboxShape.aliasEngine.resolveAlias('evt2_0')).toBe('evt2_0'); // Sandbox should resolve its own and global - expect(sandboxShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxValue'); - expect(sandboxShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + expect(sandboxShape.aliasEngine.resolveAlias('evt1_0')).toBe('sandboxValue'); + expect(sandboxShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); // Global should only resolve its own - expect(globalShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); - expect(globalShape.aliasEngine.resolveEventAlias('sandboxEvt')).toBe('sandboxEvt'); - expect(globalShape.aliasEngine.resolveEventAlias('localEvt')).toBe('localEvt'); + expect(globalShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); + expect(globalShape.aliasEngine.resolveAlias('evt1_0')).toBe('evt1_0'); + expect(globalShape.aliasEngine.resolveAlias('evt2_0')).toBe('evt_2_0'); }); it('collision detection traverses the prototype chain', () => { (logger.warn as any).mockClear(); // Register a colliding alias in local - localShape.aliasEngine.registerEventAlias('globalEvt', 'localShadow'); + localShape.aliasEngine.registerAliases('evt', [ ['globalEvt', 'localShadow'] ]); // Should warn about collision with global expect(logger.warn).toHaveBeenCalled(); - expect((logger.warn as any).mock.calls[0][0]).toMatch(/Collision detected/i); + expect((logger.warn as any).mock.calls[0][1]).toMatch(/Collision detected/i); - expect(localShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('localShadow'); - expect(globalShape.aliasEngine.resolveEventAlias('globalEvt')).toBe('globalValue'); + expect(localShape.aliasEngine.resolveAlias('2evt1')).toBe('localShadow'); + expect(globalShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); (logger.warn as any).mockReset(); }); diff --git a/packages/tempo/test/engine.alias.test.ts b/packages/tempo/test/core/alias-engine.mock.test.ts similarity index 70% rename from packages/tempo/test/engine.alias.test.ts rename to packages/tempo/test/core/alias-engine.mock.test.ts index 9f370bc..908ed9f 100644 --- a/packages/tempo/test/engine.alias.test.ts +++ b/packages/tempo/test/core/alias-engine.mock.test.ts @@ -1,4 +1,4 @@ -import { AliasEngine } from '../src/engine/engine.alias.js'; +import { AliasEngine } from '../../src/engine/engine.alias.js'; describe('AliasEngine', () => { it('assigns correct prefixes and group names for root and children', () => { @@ -8,9 +8,9 @@ describe('AliasEngine', () => { root.registerAliases('evt', [ ['rootEvent', 'rootValue'] ]); child1.registerAliases('evt', [ ['child1Event', 'child1Value'] ]); child2.registerAliases('evt', [ ['child2Event', 'child2Value'] ]); - expect(root.getAliases('evt')[0].key).toBe('0evt0'); - expect(child1.getAliases('evt')[0].key).toBe('1evt0'); - expect(child2.getAliases('evt')[0].key).toBe('1evt0'); + expect(root.getAliases('evt')[0].key).toBe('evt0_0'); + expect(child1.getAliases('evt')[0].key).toBe('evt1_0'); + expect(child2.getAliases('evt')[0].key).toBe('evt1_0'); }); it('returns correct lineage from registerEvents/registerPeriods', () => { @@ -21,13 +21,13 @@ describe('AliasEngine', () => { const periodPattern = root.registerAliases('per', periods); const eventLineage = root.getAliases('evt'); const periodLineage = root.getAliases('per'); - expect(eventPattern).toBe('(?<0evt0>a)|(?<0evt1>b)'); - expect(periodPattern).toBe('(?<0per0>x)|(?<0per1>y)'); + expect(eventPattern).toBe('(?a)|(?b)'); + expect(periodPattern).toBe('(?x)|(?y)'); - expect(eventLineage[0].key).toBe('0evt0'); - expect(eventLineage[1].key).toBe('0evt1'); - expect(periodLineage[0].key).toBe('0per0'); - expect(periodLineage[1].key).toBe('0per1'); + expect(eventLineage[0].key).toBe('evt0_0'); + expect(eventLineage[1].key).toBe('evt0_1'); + expect(periodLineage[0].key).toBe('per0_0'); + expect(periodLineage[1].key).toBe('per0_1'); }); it('resolves aliases up the proto chain', () => { @@ -35,8 +35,8 @@ describe('AliasEngine', () => { const child = new AliasEngine({ parent: root }); root.registerAliases('evt', [ ['rootEvent', 'rootValue'] ]); child.registerAliases('evt', [ ['childEvent', 'childValue'] ]); - expect(child.resolveAlias('0evt0')).toBe('rootValue'); - expect(child.resolveAlias('1evt0')).toBe('childValue'); + expect(child.resolveAlias('evt0_0')).toBe('rootValue'); + expect(child.resolveAlias('evt1_0')).toBe('childValue'); }); it('clears aliases correctly', () => { diff --git a/packages/tempo/test/core/alias-engine.test.ts b/packages/tempo/test/core/alias-engine.test.ts index a24b8ed..b03f375 100644 --- a/packages/tempo/test/core/alias-engine.test.ts +++ b/packages/tempo/test/core/alias-engine.test.ts @@ -1,103 +1,120 @@ import { AliasEngine } from '#tempo/engine/engine.alias.js'; -import { Logify } from '#library/logify.class.js'; +import type { Logify } from '#library/logify.class.js'; -// Simple logger mock -const logger = new Logify({ debug: true }); -// const logger = { -// warn: vi.fn(), -// }; - -beforeEach(() => { - // logger.warn.mockClear(); -}); +// Use a real Logify logger, but spy on console.warn +const logger = { + warn: (...args: any[]) => console.warn(...args), + debug: () => { }, + error: () => { }, + log: () => { }, + info: () => { }, + trace: () => { }, +} as unknown as Logify; describe('AliasEngine', () => { it('registers and resolves string and function aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerEventAlias('foo', 'bar'); - expect(engine.resolveEventAlias('foo')).toBe('bar'); - engine.registerPeriodAlias('noon', function () { return '12:00'; }); - expect(engine.resolvePeriodAlias('noon')).toBe('12:00'); + engine.registerAliases('evt', [['foo', 'bar']]); + expect(engine.resolveAlias('evt0_0')).toBe('bar'); + engine.registerAliases('per', [['noon', function () { return '12:00'; }]]); + expect(engine.resolveAlias('per0_0')).toBe('12:00'); }); it('supports parent/child shadowing and fallback', () => { const globalEngine = new AliasEngine({ logger }); - globalEngine.registerEventAlias('foo', 'bar'); + globalEngine.registerAliases('evt', [['foo', 'bar']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); - expect(localEngine.resolveEventAlias('foo')).toBe('bar'); - localEngine.registerEventAlias('foo', 'baz'); - expect(localEngine.resolveEventAlias('foo')).toBe('baz'); - expect(globalEngine.resolveEventAlias('foo')).toBe('bar'); + // Local should resolve parent's alias before shadowing + expect(localEngine.resolveAlias('evt0_0')).toBe('bar'); + // After shadowing, local resolves its own, parent still resolves its own + localEngine.registerAliases('evt', [['foo', 'baz']]); + expect(localEngine.resolveAlias('evt1_0')).toBe('baz'); + expect(globalEngine.resolveAlias('evt0_0')).toBe('bar'); }); it('warns on local/global collision', () => { const globalEngine = new AliasEngine({ logger }); - globalEngine.registerPeriodAlias('noon', '12:00'); + globalEngine.registerAliases('per', [['noon', '12:00']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); - localEngine.registerPeriodAlias('noon', '11:00'); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('noon')); + localEngine.registerAliases('per', [['noon', '11:00']]); + const warnCall = (console.warn as any).mock.calls[0]; + expect(warnCall[1]).toContain('noon'); }); it('warns on local collision', () => { const engine = new AliasEngine({ logger }); - engine.registerEventAlias('foo', 'bar'); - engine.registerEventAlias('foo', 'baz'); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('foo')); + engine.registerAliases('evt', [['foo', 'bar']]); + engine.registerAliases('evt', [['foo', 'baz']]); + const warnCall = (console.warn as any).mock.calls[0]; + expect(warnCall[1]).toContain('foo'); }); it('registers and resolves batch aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerEvents([ + engine.registerAliases('evt', [ ['a', '1'], ['b', '2'], ]); - expect(engine.resolveEventAlias('a')).toBe('1'); - expect(engine.resolveEventAlias('b')).toBe('2'); + expect(engine.resolveAlias('evt0_0')).toBe('1'); + expect(engine.resolveAlias('evt0_1')).toBe('2'); }); it('clears only events or periods', () => { const engine = new AliasEngine({ logger }); - engine.registerEventAlias('foo', 'bar'); - engine.registerPeriodAlias('noon', '12:00'); - engine.clear('event'); - expect(engine.resolveEventAlias('foo')).toBe('foo'); - expect(engine.resolvePeriodAlias('noon')).toBe('12:00'); - engine.clear('period'); - expect(engine.resolvePeriodAlias('noon')).toBe('noon'); + engine.registerAliases('evt', [['foo', 'bar']]); + engine.registerAliases('per', [['noon', '12:00']]); + engine.clear('evt'); + // After clearing, the alias key should not resolve to the value, but to the key itself + expect(engine.resolveAlias('evt0_0')).toBe('evt0_0'); + expect(engine.resolveAlias('per0_0')).toBe('12:00'); + engine.clear('per'); + expect(engine.resolveAlias('per0_0')).toBe('per0_0'); }); it('handles regex-like collision heuristics', () => { const globalEngine = new AliasEngine({ logger }); - globalEngine.registerPeriodAlias('noon', '12:00'); + globalEngine.registerAliases('per', [['noon', '12:00']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); - localEngine.registerPeriodAlias('([after[ -]?])?noon', '11:00'); + localEngine.registerAliases('per', [['([after[ -]?])?noon', '11:00']]); // This should warn, even if not a perfect regex match - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('noon')); + const warnCall = (console.warn as any).mock.calls[0]; + expect(warnCall[1]).toContain('noon'); }); it('does not warn on non-colliding aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerEventAlias('foo', 'bar'); - engine.registerEventAlias('baz', 'qux'); + engine.registerAliases('evt', [['foo', 'bar']]); + engine.registerAliases('evt', [['baz', 'qux']]); expect(console.warn).not.toHaveBeenCalled(); }); it('resolves to parent after clear', () => { const globalEngine = new AliasEngine({ logger }); - globalEngine.registerEventAlias('foo', 'bar'); + globalEngine.registerAliases('evt', [['foo', 'bar']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); - localEngine.registerEventAlias('foo', 'baz'); - expect(localEngine.resolveEventAlias('foo')).toBe('baz'); - localEngine.clear('event'); - expect(localEngine.resolveEventAlias('foo')).toBe('bar'); + localEngine.registerAliases('evt', [['foo', 'baz']]); + expect(localEngine.resolveAlias('evt1_0')).toBe('baz'); + + let globalAliases = globalEngine.getAliases('evt'); + let localAliases = localEngine.getAliases('evt'); + expect(localAliases[0].target).toBe('baz'); + + localEngine.clear('evt'); + globalAliases = globalEngine.getAliases('evt'); + localAliases = localEngine.getAliases('evt'); + expect(globalAliases[0].target).toBe('bar'); + expect(localAliases.length).toBe(0); + // After clearing, local should fallback to parent, but since the key is unique per depth, fallback is not automatic + // expect(localEngine.resolveAlias('evt1_0')).toBe('baz'); + expect(localEngine.resolveAlias('evt0_0')).toBe('bar'); }); it('handles empty/optional/edge-case aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerEventAlias('', 'empty'); - expect(engine.resolveEventAlias('')).toBe('empty'); - engine.registerEventAlias('?', 'question'); - expect(engine.resolveEventAlias('?')).toBe('question'); + engine.registerAliases('evt', [['', 'empty']]); + expect(engine.resolveAlias('evt0_0')).toBe('empty'); + engine.registerAliases('evt', [['?', 'question']]); + expect(engine.resolveAlias('evt0_1')).toBe('question'); }); }); From 0edbe9c450bb6c286ad2e90fb5309e3392d0699e Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 5 May 2026 13:24:22 +1000 Subject: [PATCH 15/29] test-cases --- packages/tempo/test/core/alias-engine-protochain.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tempo/test/core/alias-engine-protochain.test.ts b/packages/tempo/test/core/alias-engine-protochain.test.ts index d21bb69..16f25ea 100644 --- a/packages/tempo/test/core/alias-engine-protochain.test.ts +++ b/packages/tempo/test/core/alias-engine-protochain.test.ts @@ -29,7 +29,7 @@ describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => it('resolves local, sandbox, and global aliases in correct order', () => { // Local should resolve its own - expect(localShape.aliasEngine.resolveAlias('evt_2_0')).toBe('localValue'); + expect(localShape.aliasEngine.resolveAlias('evt2_0')).toBe('localValue'); // Local should resolve sandbox expect(localShape.aliasEngine.resolveAlias('evt1_0')).toBe('sandboxValue'); // Local should resolve global @@ -42,7 +42,7 @@ describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => // Global should only resolve its own expect(globalShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); expect(globalShape.aliasEngine.resolveAlias('evt1_0')).toBe('evt1_0'); - expect(globalShape.aliasEngine.resolveAlias('evt2_0')).toBe('evt_2_0'); + expect(globalShape.aliasEngine.resolveAlias('evt2_0')).toBe('evt2_0'); }); it('collision detection traverses the prototype chain', () => { @@ -55,7 +55,7 @@ describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => expect(logger.warn).toHaveBeenCalled(); expect((logger.warn as any).mock.calls[0][1]).toMatch(/Collision detected/i); - expect(localShape.aliasEngine.resolveAlias('2evt1')).toBe('localShadow'); + expect(localShape.aliasEngine.resolveAlias('evt2_1')).toBe('localShadow'); expect(globalShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); (logger.warn as any).mockReset(); From 8eafe0c3318d9665d486aa372f09ef47d4732351 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 5 May 2026 15:58:33 +1000 Subject: [PATCH 16/29] pre-discrete --- packages/tempo/src/discrete/discrete.parse.ts | 6 +++-- packages/tempo/src/engine/engine.alias.ts | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 59090fe..70199ee 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -326,6 +326,7 @@ const _ParseEngine = { /** 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 TempoClass = getRuntime().modules['Tempo']; + const aliasEngine = state.aliasEngine!; const prevAnchor = state.anchor; const prevZdt = state.zdt; @@ -340,8 +341,9 @@ const _ParseEngine = { const resolved = new Set(); let pending: string[]; - while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) { - const key = pending[0]; + // while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) { + while ((pending = ownKeys(groups).filter(k => (aliasEngine?.hasAlias(k) || k === 'slk') && !resolved.has(k))).length > 0) { + const key = pending[0]; if (key === 'slk') { const slk = groups[key]; diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 52df38a..dd5d343 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -22,7 +22,7 @@ import * as t from '../tempo.type.js'; export type AliasTarget = string | number | Function type AliasType = 'evt' | 'per'; - type AliasKey = `${AliasType}${number}_${number}`; +type AliasKey = `${AliasType}${number}_${number}`; type State = Record export interface AliasEngineOptions { @@ -40,6 +40,15 @@ interface Registry { // information about each registered ali } export class AliasEngine { + static aliasPattern = /^(evt|per)(\d+)_(\d+)$/; + + static #getBaseWord(s: string): string { + return s + .replace(/\[[^\]]*\]\?/g, '') + .replace(/.\?/g, '') + .replace(/[^a-z0-9]/g, ''); + } + #parent?: AliasEngineOptions["parent"]; #logger?: AliasEngineOptions["logger"]; #config?: AliasEngineOptions["config"]; @@ -70,13 +79,6 @@ export class AliasEngine { this.#count = { evt: 0, per: 0 }; } - static #getBaseWord(s: string): string { - return s - .replace(/\[[^\]]*\]\?/g, '') - .replace(/.\?/g, '') - .replace(/[^a-z0-9]/g, ''); - } - /** * Register aliases and return a regex string representing the full lineage of aliases up the proto chain. * Ensures that shadowed/collided baseNames are excluded from parent levels. @@ -132,8 +134,12 @@ export class AliasEngine { return patterns.join('|'); } - hasAlias(name: AliasKey) { - return name in this.#state; + hasAlias(name: string, type?: AliasType) { + return !(name in this.#state) + ? false + : type + ? this.#state[name as AliasKey].type === type + : true } resolveAlias(name: AliasKey, thisArg?: any) { From a65a174933a0ff42a8d6b6a997611e20af66596b Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 5 May 2026 16:39:31 +1000 Subject: [PATCH 17/29] pre for-of --- packages/tempo/src/discrete/discrete.parse.ts | 6 +++--- packages/tempo/src/support/tempo.default.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 70199ee..2f9604e 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -291,7 +291,7 @@ const _ParseEngine = { if (isEmpty(groups)) continue; - const hasTime = Object.keys(groups).some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key)) || Object.values(groups).includes('now'); + 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); @@ -342,8 +342,8 @@ const _ParseEngine = { let pending: string[]; // while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) { - while ((pending = ownKeys(groups).filter(k => (aliasEngine?.hasAlias(k) || k === 'slk') && !resolved.has(k))).length > 0) { - const key = pending[0]; + while ((pending = ownKeys(groups).filter(k => (aliasEngine?.hasAlias(k) || k === 'slk' || Match.named.test(k)) && !resolved.has(k))).length > 0) { + const key = pending[0]; if (key === 'slk') { const slk = groups[key]; diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 1e32b8c..84631d3 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -17,8 +17,9 @@ const bracket_content = /[^\]]+/; export const Match = proxify({ /** match all {} pairs, if they start with a word char */ braces: /{([#]?[\w]+(?:\.[\w]+)*)}/g, /** named capture-group, if it starts with a letter */ captures: /\(\?<([a-zA-Z][\w]*)>(.*?)(?[0-9]+)$|^g?dt$/, - /** period */ period: /^([gl])?per(?[0-9]+)$|^g?tm$/, + /** event */ event: /^evt\d+_\d+$/, + /** period */ period: /^per\d+_\d+$/, + /** structural */ named: /^g?dt$|^g?tm$/, /** two digit year */ twoDigit: /^[0-9]{2}$/, /** date */ date: /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, /** time */ time: /^[0-9]{2}:[0-9]{2}(:[0-9]{2})?$/, From 7898eaf3e33b16ce062e37e89903eb83d1311547 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 5 May 2026 16:59:23 +1000 Subject: [PATCH 18/29] pre alias-resolve --- packages/tempo/src/discrete/discrete.parse.ts | 49 +++++++------------ packages/tempo/src/engine/engine.alias.ts | 6 ++- 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 2f9604e..0f68c53 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -339,15 +339,14 @@ const _ParseEngine = { try { const resolved = new Set(); - let pending: string[]; - // while ((pending = ownKeys(groups).filter(k => (Match.event.test(k) || Match.period.test(k) || k === 'slk') && !resolved.has(k))).length > 0) { - while ((pending = ownKeys(groups).filter(k => (aliasEngine?.hasAlias(k) || k === 'slk' || Match.named.test(k)) && !resolved.has(k))).length > 0) { - const key = pending[0]; + for (const key of ownKeys(groups)) { + if (resolved.has(key)) continue; if (key === 'slk') { const slk = groups[key]; const result = resolveTermMutation(TempoClass, state as any, 'set', slk, undefined, dateTime); + if (result === null) { state.errored = true; resolved.add(key); @@ -360,30 +359,16 @@ const _ParseEngine = { continue; } - const isEvent = Match.event.test(key); - const isGlobal = key.startsWith('g'); - const isNamed = key === 'gdt' || key === 'dt' || key === 'gtm' || key === 'tm'; - const idx = isNamed ? -1 : +(key.match(/\d+$/)?.[0] ?? -1); - - if (isNamed) { + if (Match.named.test(key)) { // remove structural markers resolved.add(key); delete groups[key]; continue; } - const globalParse = isGlobal ? (TempoClass as any)?.[sym.$Internal]?.().parse : undefined; - const src = isGlobal - ? (isEvent ? globalParse?.event : globalParse?.period) - : (isEvent ? state.parse.event : state.parse.period); - const entry = ownEntries(src, true)[idx]; + const register = aliasEngine.getAlias(key); + if (!register) continue; - if (!entry) { - resolved.add(key); - delete groups[key]; - continue; - } - - const aliasKey = entry[0] as string; + const aliasKey = register.name; if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) { const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; state.errored = true; @@ -396,18 +381,16 @@ const _ParseEngine = { resolvingKeys.add(aliasKey); resolved.add(key); - const definition = entry[1]; + const isEvent = register.type === 'evt'; + const definition = register.target; const isFn = isFunction(definition); let res: string = ''; + if (isFn) { // Provide a lightweight host context that mimics a Tempo instance for the handler const host = { - add: (val: any) => { - return dateTime.add(val); - }, - subtract: (val: any) => { - return dateTime.subtract(val); - }, + 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); @@ -448,10 +431,12 @@ const _ParseEngine = { try { const type = isEvent ? 'Event' : 'Period'; - const pat = (isEvent ? 'dt' : 'tm'); + const pat = isEvent ? 'dt' : 'tm'; const resolveVal = isFn ? res : definition; - const source = isGlobal ? 'global' : 'local'; - _ParseEngine.result(state, { type, value: entry[0] as any, match: pat, source, groups: { [key]: resolveVal as string } }); + const depth = parseInt(key.match(/\d+/)?.[0] ?? '0'); + const source = depth === 0 ? 'global' : 'local'; + + _ParseEngine.result(state, { type, value: aliasKey as any, match: pat, source, groups: { [key]: resolveVal as string } }); // Protect against recursive re-evaluation of same alias if (!isEmpty(res) && res !== String(groups[key])) { diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index dd5d343..7638a26 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -147,10 +147,14 @@ export class AliasEngine { if (!register) return name; return isFunction(register.target) - ? register.target.call(thisArg) + ? register.target.call(thisArg).toString() : register.target; } + getAlias(key: string): Registry | undefined { + return this.#state[key as AliasKey]; + } + getAliases(type?: AliasType) { const aliases = [] as Registry[]; From 03670a070c9feb18084f1496d55d3bf1d7b9d6d0 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Tue, 5 May 2026 17:44:45 +1000 Subject: [PATCH 19/29] pre test-fails --- packages/tempo/src/discrete/discrete.parse.ts | 101 +++++++----------- packages/tempo/src/engine/engine.alias.ts | 13 ++- packages/tempo/src/support/tempo.default.ts | 1 + 3 files changed, 49 insertions(+), 66 deletions(-) diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 0f68c53..715ffd8 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -326,7 +326,7 @@ const _ParseEngine = { /** 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 TempoClass = getRuntime().modules['Tempo']; - const aliasEngine = state.aliasEngine!; + const aliasEngine = state.aliasEngine; const prevAnchor = state.anchor; const prevZdt = state.zdt; @@ -338,34 +338,28 @@ const _ParseEngine = { if (isRoot) state.matches = []; try { - const resolved = new Set(); - for (const key of ownKeys(groups)) { - if (resolved.has(key)) continue; - if (key === 'slk') { const slk = groups[key]; const result = resolveTermMutation(TempoClass, state as any, 'set', slk, undefined, dateTime); if (result === null) { state.errored = true; - resolved.add(key); delete groups[key]; break; } + dateTime = result; - resolved.add(key); delete groups[key]; continue; } - if (Match.named.test(key)) { // remove structural markers - resolved.add(key); + if (Match.named.test(key)) { // remove structural markers delete groups[key]; continue; } - const register = aliasEngine.getAlias(key); + const register = aliasEngine?.getAlias(key); if (!register) continue; const aliasKey = register.name; @@ -373,73 +367,55 @@ const _ParseEngine = { 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)); - resolved.add(key); delete groups[key]; continue; } resolvingKeys.add(aliasKey); - resolved.add(key); + const isFn = isFunction(register.target); + + // Provide a lightweight host context that mimics a Tempo instance for the handler + const host = { + 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 + }; + + const res = String(aliasEngine?.resolveAlias(key as any, host) ?? ''); const isEvent = register.type === 'evt'; - const definition = register.target; - const isFn = isFunction(definition); - let res: string = ''; - - if (isFn) { - // Provide a lightweight host context that mimics a Tempo instance for the handler - const host = { - 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 - }; - - const result = (definition as Function).call(host); - if (isString(result) && /^(?:[01]?\d|2[0-3]):[0-5]\d$/.test(result)) { - const [hourStr, minuteStr] = result.split(':'); - const hour = Number(hourStr); - const minute = Number(minuteStr); - dateTime = dateTime.with({ hour, minute, second: 0, millisecond: 0 }); - res = ''; - } else if (isTempo(result)) { - dateTime = (result as any).toDateTime(); - } else if (isZonedDateTime(result)) { - dateTime = result as Temporal.ZonedDateTime; - } else if (isObject(result) && isFunction((result as any).toDateTime)) { - dateTime = (result as any).toDateTime(); - } else { - res = isString(result) || isNumeric(result) ? String(result) : ''; - } - state.zdt = dateTime; - } else { - res = (definition as string); - } try { const type = isEvent ? 'Event' : 'Period'; const pat = isEvent ? 'dt' : 'tm'; - const resolveVal = isFn ? res : definition; const depth = parseInt(key.match(/\d+/)?.[0] ?? '0'); const source = depth === 0 ? 'global' : 'local'; - _ParseEngine.result(state, { type, value: aliasKey as any, match: pat, source, groups: { [key]: resolveVal as string } }); + _ParseEngine.result(state, { type, value: aliasKey as any, match: pat, source, groups: { [key]: res } }); - // Protect against recursive re-evaluation of same alias - if (!isEmpty(res) && res !== String(groups[key])) { + // If the alias resolved to a time-snap (hh:mm), we handle it directly + if (isFn && Match.clock.test(res)) { + const [hourStr, minuteStr] = res.split(':'); + const hour = Number(hourStr); + const minute = Number(minuteStr); + dateTime = dateTime.with({ hour, minute, second: 0, millisecond: 0 }); + } + // Otherwise, if it resolved to a new string, we re-parse it + else if (!isEmpty(res) && res !== String(groups[key])) { const resolving = new Set(resolvingKeys); resolving.add(aliasKey); // Explicitly propagate anchor for recursive parse @@ -452,6 +428,7 @@ const _ParseEngine = { dateTime = resMatch.value; } } finally { + state.zdt = dateTime; delete groups[key]; } } diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 7638a26..93480af 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -16,7 +16,7 @@ import type { Nullable } from '#library/type.library.js'; import type { Logify } from '#library/logify.class.js'; -import { isFunction } from '#library'; +import { isDefined, isFunction } from '#library'; import { ownEntries } from '#library/primitive.library.js'; import * as t from '../tempo.type.js'; @@ -146,9 +146,14 @@ export class AliasEngine { const register = this.#state[name]; if (!register) return name; - return isFunction(register.target) - ? register.target.call(thisArg).toString() - : register.target; + if (isFunction(register.target)) { + const result = register.target.call(thisArg); + return isDefined(result) + ? result.toString() + : '' + } + + return register.target; } getAlias(key: string): Registry | undefined { diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 84631d3..c3bbc73 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -23,6 +23,7 @@ export const Match = proxify({ /** two digit year */ twoDigit: /^[0-9]{2}$/, /** date */ date: /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, /** time */ time: /^[0-9]{2}:[0-9]{2}(:[0-9]{2})?$/, + /** clock (HH:mm) */ clock: /^(?:[01]?\d|2[0-3]):[0-5]\d$/, /** separator characters (/ - . , T) */ separator: /[T\/\-\.\s,]/, /** modifier characters (+-<>=) */ modifier: /[\+\-\<\>][\=]?|this|next|prev|last/, /** offset post keywords (ago|hence) */ affix: /ago|hence|from now/, From 103fb5c9f807cb25a0e7376976201690d995ea72 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 10:31:37 +1000 Subject: [PATCH 20/29] ready for review --- packages/tempo/src/discrete/discrete.parse.ts | 23 +-- packages/tempo/src/engine/engine.alias.ts | 35 +++-- packages/tempo/src/engine/engine.composer.ts | 30 ++-- packages/tempo/src/engine/engine.lexer.ts | 14 +- packages/tempo/src/support/support.index.ts | 2 +- packages/tempo/src/support/tempo.default.ts | 16 +- packages/tempo/src/support/tempo.init.ts | 8 +- packages/tempo/src/support/tempo.runtime.ts | 2 + packages/tempo/src/support/tempo.util.ts | 27 +++- packages/tempo/src/tempo.class.ts | 141 +++++++++++------- 10 files changed, 192 insertions(+), 106 deletions(-) diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 715ffd8..a0f5d9c 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -131,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); + const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal, (m) => _ParseEngine.result(state, m), state.config.timeStamp, state.config); dateTime = dt; if (timeZone && state) state.config.timeZone = timeZone; @@ -291,13 +291,16 @@ const _ParseEngine = { if (isEmpty(groups)) continue; - 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'); + 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); - dateTime = parseWeekday(groups, dateTime, (TempoClass as any)?.[sym.$dbg], state.config); - dateTime = parseDate(groups, dateTime, (TempoClass as any)?.[sym.$dbg], state.config, state.parse["pivot"]); + if (state.errored) return arg; + + dateTime = parseWeekday(groups, dateTime, state.config); + dateTime = parseDate(groups, dateTime, state.config, state.parse["pivot"]); dateTime = parseTime(groups, dateTime); const isChanged = !dateTime.toPlainTime().equals(anchorTime); @@ -325,8 +328,6 @@ const _ParseEngine = { /** 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 TempoClass = getRuntime().modules['Tempo']; - const aliasEngine = state.aliasEngine; const prevAnchor = state.anchor; const prevZdt = state.zdt; @@ -337,6 +338,9 @@ const _ParseEngine = { 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') { @@ -407,12 +411,13 @@ const _ParseEngine = { _ParseEngine.result(state, { type, value: aliasKey as any, match: pat, source, groups: { [key]: res } }); - // If the alias resolved to a time-snap (hh:mm), we handle it directly + // If the alias resolved to a time-snap (hh:mm[:ss]), we handle it directly if (isFn && Match.clock.test(res)) { - const [hourStr, minuteStr] = res.split(':'); + const [hourStr, minuteStr, secondStr = '0'] = res.split(':'); const hour = Number(hourStr); const minute = Number(minuteStr); - dateTime = dateTime.with({ hour, minute, second: 0, millisecond: 0 }); + const second = Number(secondStr); + dateTime = dateTime.with({ hour, minute, second, millisecond: 0 }); } // Otherwise, if it resolved to a new string, we re-parse it else if (!isEmpty(res) && res !== String(groups[key])) { diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 93480af..4000e1d 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -41,9 +41,11 @@ interface Registry { // information about each registered ali export class AliasEngine { static aliasPattern = /^(evt|per)(\d+)_(\d+)$/; + static #idCounter = 0; static #getBaseWord(s: string): string { return s + .toLowerCase() .replace(/\[[^\]]*\]\?/g, '') .replace(/.\?/g, '') .replace(/[^a-z0-9]/g, ''); @@ -57,11 +59,19 @@ export class AliasEngine { #count: Record; // count of aliases registered at this level (used for indexing) #state: State; // object that holds alias mappings, collisions, and registry for this engine #words: Record; // object of base words for collision detection + #id: number; + + get depth() { + return this.#depth + } + get id() { return this.#id } + get parent() { return this.#parent } constructor(options = {} as AliasEngineOptions) { this.#parent = options.parent ?? null; this.#logger = options.logger; this.#config = options.config; + this.#id = AliasEngine.#idCounter++; if (this.#parent) { if (!(this.#parent instanceof AliasEngine)) @@ -88,7 +98,9 @@ export class AliasEngine { */ registerAliases(type: AliasType, events: [string, AliasTarget][]) { for (const [name, target] of events) { - const aliasKey = `${type}${this.#depth}_${this.#count[type]++}` as AliasKey; + const index = (this.#count[type]++); + const aliasKey = `${type}${this.#depth}_${index}` as AliasKey; + const baseWord = AliasEngine.#getBaseWord(name); const collision = baseWord in this.#words; // check for collision with existing base words in this engine and parent engines @@ -116,12 +128,12 @@ export class AliasEngine { * it won't be included in the regex patterns of the parent engine, * preventing unintended matches and preserving the expected behavior of alias resolution. */ - getPatterns(type: AliasType): string { + getPatterns(type: AliasType, seenBaseNames = new Set()): string { const patterns: string[] = []; - const seenBaseNames = new Set(); - for (const alias in this.#state) { - const register = this.#state[alias as AliasKey]; + const state = this.#state; + for (const alias in state) { + const register = state[alias as AliasKey]; if (!seenBaseNames.has(register.baseWord)) { seenBaseNames.add(register.baseWord); @@ -131,6 +143,11 @@ export class AliasEngine { } } + if (this.#parent) { + const parentPatterns = this.#parent.getPatterns(type, seenBaseNames); + if (parentPatterns) patterns.push(parentPatterns); + } + return patterns.join('|'); } @@ -142,8 +159,8 @@ export class AliasEngine { : true } - resolveAlias(name: AliasKey, thisArg?: any) { - const register = this.#state[name]; + resolveAlias(name: AliasKey, thisArg?: any): Nullable { + const register = this.getAlias(name); if (!register) return name; if (isFunction(register.target)) { @@ -153,11 +170,11 @@ export class AliasEngine { : '' } - return register.target; + return register.target as string; } getAlias(key: string): Registry | undefined { - return this.#state[key as AliasKey]; + return this.#state[key as AliasKey] ?? this.#parent?.getAlias(key); } getAliases(type?: AliasType) { diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index f2f164e..d1fcc5a 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -1,4 +1,4 @@ -import { isTempo, Match } from '#tempo/support'; +import { isTempo, logError } from '#tempo/support'; import { isNumeric, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/assertion.library.js'; import type { TemporalObject, TypeValue } from '#library/type.library.js'; import type { Tempo } from '#tempo/tempo.class.js'; @@ -15,7 +15,8 @@ export function compose( targetTz: string, targetCal: string, onResult?: (match: any) => void, - unit: t.Internal.TimeStamp = 'ms' + unit: t.Internal.TimeStamp = 'ms', + config?: any ): { dateTime: Temporal.ZonedDateTime, timeZone?: string | undefined } { let temporal: TemporalObject | Tempo = today; let timeZone: string | undefined; @@ -37,18 +38,11 @@ export function compose( temporal = zdt; onResult?.({ type, value: str, match: 'iso8601' }); } catch (err) { - if (Match.date.test(value)) { - try { - temporal = Temporal.PlainDate.from(value); - break; - } catch { /* ignore and fallback */ } - } - try { - temporal = Temporal.PlainDateTime.from(value); + temporal = Temporal.PlainDateTime.from(value, { overflow: 'constrain' }); } catch (err2) { - // security check: do not let native Date take a guess on garbage strings - throw new Error(`Cannot parse Date: "${value}"`); + logError(config, `[Tempo#composer] Unrecognized or invalid ISO 8601 string: "${value}"`); + return { dateTime: today }; } } break; @@ -80,8 +74,10 @@ export function compose( case 'Number': { - if (Number.isNaN(value) || !Number.isFinite(value)) - throw new RangeError(`Invalid Tempo number: ${value}`); + if (Number.isNaN(value) || !Number.isFinite(value)) { + logError(config, `Invalid Tempo number: ${value}`); + temporal = today; + } // If it's an integer and we're in 'ms' mode, treat as milliseconds if (unit === 'ms' && Number.isInteger(value)) { @@ -149,8 +145,10 @@ export function compose( dateTime = temporal.toDateTime().withCalendar(targetCal); break; - default: - throw new Error(`Cannot convert ${type} (value: ${String(temporal)}) to ZonedDateTime`); + default: { + logError(config, `Cannot convert ${type} (value: ${String(temporal)}) to ZonedDateTime`); + return { dateTime: today }; + } } return { dateTime, timeZone }; diff --git a/packages/tempo/src/engine/engine.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts index 1faf895..acac968 100644 --- a/packages/tempo/src/engine/engine.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -2,7 +2,7 @@ import '#library/temporal.polyfill.js'; import { isString, isEmpty, isUndefined, isDefined, isTemporal, isInstant } from '#library/assertion.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import { pad, singular } from '#library/string.library.js'; -import { Match, enums, isTempo } from '#tempo/support'; +import { Match, enums, isTempo, logError, logWarn } from '#tempo/support'; import * as t from '../tempo.type.js'; /** @@ -112,7 +112,7 @@ export function parseModifier({ mod, adjust, offset, period }: Lexer.GroupModifi * * @returns ZonedDateTime with computed date-offset */ -export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, logger: any, config: any): Temporal.ZonedDateTime { +export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, config: any): Temporal.ZonedDateTime { const { wkd, mod, nbr = '1', sfx, afx, ...rest } = groups as Lexer.GroupWkd; if (isUndefined(wkd)) return dateTime; @@ -121,7 +121,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, return dateTime; if (!isEmpty(mod) && !isEmpty(sfx)) { - logger.warn(config, `Cannot provide both a modifier '${mod}' and suffix '${sfx}'`); + logWarn(config, `Cannot provide both a modifier '${mod}' and suffix '${sfx}'`); return dateTime; } @@ -130,7 +130,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, const offset = (enums.WEEKDAY as any)[weekday] ?? (enums.WEEKDAYS as any)[weekday]; if (!Number.isFinite(offset)) { - logger.error(config, `Invalid weekday token: "${wkd}"`); + logError(config, `Invalid weekday token: "${wkd}"`); return dateTime; } @@ -146,7 +146,7 @@ export function parseWeekday(groups: t.Groups, dateTime: Temporal.ZonedDateTime, } /** resolve a date pattern match */ -export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, logger: any, config: any, pivot: number = 75): Temporal.ZonedDateTime { +export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, config: any, pivot: number = 75): Temporal.ZonedDateTime { const { mod, nbr = '1', afx, unt } = groups as Lexer.GroupDate; // Normalize yy, mm, dd: treat empty string as missing @@ -158,7 +158,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, lo return dateTime; if (!isEmpty(mod) && !isEmpty(afx)) { - logger.warn(config, `Cannot provide both a modifier '${mod}' and suffix '${afx}'`); + logWarn(config, `Cannot provide both a modifier '${mod}' and suffix '${afx}'`); return dateTime; } @@ -211,7 +211,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, lo delete groups["afx"]; if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { - logger.error(config, `Invalid Date components: year=${year}, month=${month}, day=${day}`); + logError(config, `Invalid Date components: year=${year}, month=${month}, day=${day}`); return dateTime; } diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index c21a92e..8c45315 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -32,5 +32,5 @@ export { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $e export { registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; export { getRuntime, TempoRuntime } from './tempo.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './tempo.default.js'; -export { SCHEMA, getLargestUnit, setPatterns } from './tempo.util.js'; +export { SCHEMA, getLargestUnit, setPatterns, logError, logWarn, logDebug } from './tempo.util.js'; export { init, extendState } from './tempo.init.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index c3bbc73..52ae76a 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -21,9 +21,9 @@ export const Match = proxify({ /** period */ period: /^per\d+_\d+$/, /** structural */ named: /^g?dt$|^g?tm$/, /** two digit year */ twoDigit: /^[0-9]{2}$/, - /** date */ date: /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, - /** time */ time: /^[0-9]{2}:[0-9]{2}(:[0-9]{2})?$/, - /** clock (HH:mm) */ clock: /^(?:[01]?\d|2[0-3]):[0-5]\d$/, + /** date (ISO 8601) */ date: /^(?:[+-][0-9]{6}|[0-9]{4})-?(?:0[1-9]|1[0-2])-?(?:0[1-9]|[12][0-9]|3[01])$/, + /** time (HH:mm[:ss]) */ time: /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/, + /** clock (HH:mm[:ss]) */ clock: /^(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/, /** separator characters (/ - . , T) */ separator: /[T\/\-\.\s,]/, /** modifier characters (+-<>=) */ modifier: /[\+\-\<\>][\=]?|this|next|prev|last/, /** offset post keywords (ago|hence) */ affix: /ago|hence|from now/, @@ -117,7 +117,7 @@ export type Layout = typeof Layout export const Event = looseIndex()({ 'new.?years? ?eve': '31 Dec', 'nye': '31 Dec', - 'new.?years?( ?day)?': '01 Jan', + 'new.?years?(?: ?day)?': '01 Jan', 'ny': '01 Jan', 'christmas ?eve': '24 Dec', 'christmas': '25 Dec', @@ -200,5 +200,11 @@ export const Default = secure({ /** regional date-parsing configuration */ monthDay: MONTH_DAY, /** internationalization configuration */ intl: IntlDefault, - /** parse planner configuration (layoutOrder, etc.) */ planner: { layoutOrder: [], preFilter: false }, + /** parse planner configuration (layoutOrder, etc.) */ planner: { + // layoutOrder: [ + // Token.hms, Token.dmy6, Token.mdy6, Token.ymd6, Token.wkd, + // Token.dt, Token.tm, Token.dtm, Token.tmd, Token.dmy, Token.mdy, Token.ymd, + // Token.off, Token.rel + // ], preFilter: false + }, } as Options) diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index 5d15b76..3f2018b 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -36,10 +36,10 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int event: Object.assign({}, baseState?.parse.event ?? Event), period: Object.assign({}, baseState?.parse.period ?? Period), ignore: baseState ? { ...baseState.parse.ignore } : Object.fromEntries(asArray(Ignore).map(w => [w, w])), - monthDay: resolveMonthDay(baseState?.parse.monthDay ?? {}, Default.monthDay as any), - planner: { - layoutOrder: asArray(baseState?.parse.planner?.layoutOrder ?? (Default.planner?.layoutOrder ?? (Default as any).layoutOrder)), - preFilter: Boolean(baseState?.parse.planner?.preFilter ?? (Default.planner?.preFilter ?? (Default as any).preFilter)), + monthDay: baseState ? Object.create(baseState.parse.monthDay) : resolveMonthDay({}, Default.monthDay as any), + planner: baseState ? Object.create(baseState.parse.planner) : { + layoutOrder: asArray(Default.planner?.layoutOrder ?? (Default as any).layoutOrder), + preFilter: Boolean(Default.planner?.preFilter ?? (Default as any).preFilter), }, pivot: (baseState?.parse.pivot ?? Default.pivot) as any, mode: (baseState?.parse.mode ?? Default.mode) as any, diff --git a/packages/tempo/src/support/tempo.runtime.ts b/packages/tempo/src/support/tempo.runtime.ts index 752848b..3e439c6 100644 --- a/packages/tempo/src/support/tempo.runtime.ts +++ b/packages/tempo/src/support/tempo.runtime.ts @@ -45,6 +45,8 @@ export class TempoRuntime { /** persistent global configuration state — mirrors Tempo.#global */ state?: Internal.State | undefined; + /** centralized diagnostic logger — shared across all Tempo modules */ + logger?: any | undefined; /** cache for next-available 'usr' Token key */ usrCount: number = 0; diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts index 1c4acb5..c43beb2 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -28,8 +28,25 @@ export const setProperty = (target: object, key: PropertyKey, value: T) => { /** @internal set multiple mutable, enumerable properties on a target */ export const setProperties = (target: object, properties: Record) => { - ownEntries(properties) - .forEach(([key, value]) => setProperty(target, key, value)); + ownEntries(properties).forEach(([key, value]) => setProperty(target, key, value)); +} + +/** @internal Centralized Error Logger — retrieves the shared Logify instance from the TempoRuntime */ +export function logError(config: any, ...msg: any[]) { + const rt = getRuntime(); + rt.logger?.error(config ?? rt.state?.config, ...msg); +} + +/** @internal Centralized Warning Logger — retrieves the shared Logify instance from the TempoRuntime */ +export function logWarn(config: any, ...msg: any[]) { + const rt = getRuntime(); + rt.logger?.warn(config ?? rt.state?.config, ...msg); +} + +/** @internal Centralized Debug Logger — retrieves the shared Logify instance from the TempoRuntime */ +export function logDebug(config: any, ...msg: any[]) { + const rt = getRuntime(); + rt.logger?.debug(config ?? rt.state?.config, ...msg); } /** @internal return the Prototype parent of an object */ @@ -152,7 +169,7 @@ export function compileRegExp(layout: string | RegExp, state: t.Internal.State, export function setPatterns(state: t.Internal.State) { // ensure we have our own isolated mutable containers before mutation state.parse.snippet = { ...state.parse.snippet }; - state.parse.pattern = new Map(state.parse.pattern); + state.parse.pattern = new Map(); const snippet = state.parse.snippet; @@ -230,7 +247,7 @@ export function resolveMonthDay(value: t.MonthDay | boolean = {}, base: t.MonthD return { locale: intl.baseName, timeZones: tzs_intl.length > 0 ? tzs_intl : fallback - }; + } }); return { @@ -240,5 +257,5 @@ export function resolveMonthDay(value: t.MonthDay | boolean = {}, base: t.MonthD layouts: layoutsList as any, timezones: tzs, resolvedLocales - }; + } } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 6c60273..be37d27 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -25,7 +25,7 @@ import { AliasEngine } from './engine/engine.alias.js'; import { resolveMonthDay } from './support/tempo.util.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './parse/parse.layout.js'; import { datePattern } from './support/tempo.default.js'; -import { setProperty, proto, hasOwn, create, compileRegExp, setPatterns, normalizeLayoutOrder } from './support/tempo.util.js'; +import { setProperty, proto, hasOwn, compileRegExp, setPatterns, normalizeLayoutOrder } from './support/tempo.util.js'; import { sym, markConfig, TermError, getRuntime, init, extendState, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, $Internal, $setConfig, $logError, $logDebug, $Identity, $setEvents, $setPeriods, $buildGuard, $IsBase, $Tempo, $Register, $Logify, $errored, $dbg, $guard, $Discover, $setDiscovery } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) @@ -142,27 +142,45 @@ export class Tempo { */ // TODO: check all Layouts which reference "{evt}" and update them static [$setEvents](shape: Internal.State) { - const events = ownEntries(shape.parse.event, true); - if (isLocal(shape) && !hasOwn(shape.parse, 'event') && !hasOwn(shape.parse.monthDay, 'active')) - return; // no local change needed - - // Use the correct alias engine: static for global, instance for local, and assign parentEngine for locals const parent = proto(shape); - const engine = hasOwn(shape, 'aliasEngine') - ? shape.aliasEngine! - : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg, config: shape.config })); - const groups = engine.registerAliases('evt', events); + const parentEvents = parent.parse?.event ?? {}; + + // Identify local additions or overrides + const events = ownEntries(shape.parse.event, true).filter(([k, v]) => { + return !(k in parentEvents) || shape.parse.event[k as string] !== parentEvents[k as string]; + }); + + // If no local events, inherit the parent's engine (via prototype) and exit + if (events.length === 0 && !hasOwn(shape, 'aliasEngine')) + return; + + // Use the correct alias engine: static for global, instance for local + let engine = shape.aliasEngine; + + // If we have local aliases to register, we MUST have a local engine to avoid polluting the parent + if (events.length > 0 && !hasOwn(shape, 'aliasEngine')) { + engine = shape.aliasEngine = new AliasEngine({ + parent: parent.aliasEngine, + logger: Tempo.#dbg, + config: shape.config + }); + } - // const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') - // const groups = events - // .map(([pat, _], idx) => `(?<${src}evt${idx}>${pat})`) // assign a number to the pattern - // .join('|') // make an 'Or' pattern for the event-keys + // Ensure we have an engine (for the global root) + if (!engine) { + engine = shape.aliasEngine = new AliasEngine({ + parent: parent.aliasEngine, + logger: Tempo.#dbg, + config: shape.config + }); + } + const groups = engine.registerAliases('evt', events); if (groups) { - const protoEvt = proto(shape.parse.snippet)[Token.evt]?.source; - if (!isLocal(shape) || groups !== protoEvt) { - if (isLocal(shape) && !hasOwn(shape.parse, 'snippet')) - shape.parse.snippet = create(shape.parse, 'snippet'); + const protoEvt = parent.parse?.snippet?.[Token.evt]?.source; + if (groups !== protoEvt) { + if (!hasOwn(shape.parse, 'snippet')) + shape.parse.snippet = { ...shape.parse.snippet }; setProperty(shape.parse.snippet, Token.evt, new RegExp(groups)); } @@ -172,17 +190,6 @@ export class Tempo { delete shape.parse.snippet[Token.evt as any]; } } - - const isMonthDay = Boolean(shape.parse.monthDay.active); - const protoDt = proto(shape.parse.layout)[Token.dt] as string; - const targetDt = isMonthDay ? datePattern.mdy : datePattern.dmy; - - if (!isLocal(shape) || targetDt !== protoDt) { - if (isLocal(shape) && !hasOwn(shape.parse, 'layout')) - shape.parse.layout = create(shape.parse, 'layout'); - - setProperty(shape.parse.layout, Token.dt, targetDt); - } } /** @@ -191,27 +198,46 @@ export class Tempo { */ // TODO: check all Layouts which reference "{per}" and update them static [$setPeriods](shape: Internal.State) { - const periods = ownEntries(shape.parse.period, true); - if (isLocal(shape) && !hasOwn(shape.parse, 'period')) - return; // no local change needed + const parent = proto(shape); + const parentPeriods = parent.parse?.period ?? {}; + + // Identify local additions or overrides + const periods = ownEntries(shape.parse.period, true).filter(([k, v]) => { + return !(k in parentPeriods) || shape.parse.period[k as string] !== parentPeriods[k as string]; + }); + + // If no local periods, inherit the parent's engine (via prototype) and exit + if (periods.length === 0 && !hasOwn(shape, 'aliasEngine')) + return; // Use the correct alias engine: static for global, instance for local - const parent = proto(shape); - const engine = hasOwn(shape, 'aliasEngine') - ? shape.aliasEngine! - : (shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg, config: shape.config })); - const groups = engine.registerAliases('per', periods); + let engine = shape.aliasEngine; + + // If we have local aliases to register, we MUST have a local engine to avoid polluting the parent + if (periods.length > 0 && !hasOwn(shape, 'aliasEngine')) { + engine = shape.aliasEngine = new AliasEngine({ + parent: parent.aliasEngine, + logger: Tempo.#dbg, + config: shape.config + }); + } - // const src = shape.config.scope === 'global' ? 'g' : 'l';// 'g'lobal or 'l'ocal (sandbox also uses 'l') - // const groups = periods - // .map(([pat, _], idx) => `(?<${src}per${idx}>${pat})`) // {pattern} is the 1st element of the tuple - // .join('|') // make an 'or' pattern for the period-keys + // Ensure we have an engine (for the global root) + if (!engine) { + engine = shape.aliasEngine = new AliasEngine({ + parent: parent.aliasEngine, + logger: Tempo.#dbg, + config: shape.config + }); + } + + const groups = engine.registerAliases('per', periods); if (groups) { - const protoPer = proto(shape.parse.snippet)[Token.per]?.source; - if (!isLocal(shape) || groups !== protoPer) { - if (isLocal(shape) && !hasOwn(shape.parse, 'snippet')) - shape.parse.snippet = create(shape.parse, 'snippet'); + const protoPer = parent.parse?.snippet?.[Token.per]?.source; + if (groups !== protoPer) { + if (!hasOwn(shape.parse, 'snippet')) + shape.parse.snippet = { ...shape.parse.snippet }; setProperty(shape.parse.snippet, Token.per, new RegExp(groups)); } @@ -229,8 +255,9 @@ export class Tempo { const tz = options.timeZone; if (isDefined(tz)) { - if (String(tz).toLowerCase() === 'utc') return undefined; - const sphere = getHemisphere(String(tz)); + const resolvedTz = shape.config.timeZone; + if (String(resolvedTz).toLowerCase() === 'utc') return undefined; + const sphere = getHemisphere(String(resolvedTz)); if (isDefined(sphere)) return sphere; } @@ -271,6 +298,11 @@ export class Tempo { const isMonthDay = shape.parse.monthDay.isExplicit ? shape.parse.monthDay.active! : Tempo.#isMonthDay(shape); shape.parse.monthDay.active = isMonthDay; + // ensure Token.dt matches the local monthDay preference + const dt = isMonthDay ? datePattern.mdy : datePattern.dmy; + if (shape.parse.layout[Token.dt] !== dt) + shape.parse.layout = { ...shape.parse.layout, [Token.dt]: dt }; + const layoutController = (shape.parse.planner.layoutOrder?.length ?? 0) > 0 ? { [DEFAULT_LAYOUT_CLASS]: [...shape.parse.planner.layoutOrder!] } : undefined; @@ -748,6 +780,7 @@ export class Tempo { const rt = getRuntime(); rt.state = undefined; // force fresh state const state = init(); + (state as any)._count = 0; if ((this as any)[sym.$IsBase]) { Tempo.#global = state; } else { @@ -1028,6 +1061,7 @@ export class Tempo { }); Tempo.init(); // synchronously initialize the library + getRuntime().logger = Tempo.#dbg; } /** constructor tempo */ #tempo?: t.DateTime; @@ -1064,7 +1098,7 @@ export class Tempo { */ [$Internal]() { const self: Tempo = unwrap(this); - return { + const out = { get zdt() { return self.#zdt }, set zdt(val: any) { self.#zdt = val }, get errored() { return self.#errored }, @@ -1082,10 +1116,15 @@ export class Tempo { get now() { return self.#now }, config: self.#local.config, parse: self.#local.parse, + aliasEngine: self.#local.aliasEngine, + _id: (self.#local as any)._id, + tempoInstance: self, CONFIG: enums.CONFIG, PARSE: enums.PARSE, ZONED_DATE_TIME: enums.ZONED_DATE_TIME } + + return out; } /** allow for auto-convert of Tempo to BigInt, Number or String */ @@ -1106,7 +1145,6 @@ export class Tempo { return 'Tempo'; // hard-coded to avoid minification mangling } - /** * Instantiates a new `Tempo` object with configuration only. * @@ -1460,11 +1498,14 @@ export class Tempo { #setLocal(options: t.Options = {}) { const classState = (this.constructor as any)[$Internal](); this.#local = Object.create(classState); + (this.#local as any)._id = (this.constructor as any)[$Internal]()._count++; + const self = unwrap(this); this.#local.config = markConfig(Object.create(classState.config)); Object.assign(this.#local.config, { scope: 'local' }); this.#local.parse = markConfig(Object.create(classState.parse)); - this.#local.parse.planner = { ...classState.parse.planner }; // clone the planner object + this.#local.parse.planner = Object.create(classState.parse.planner); // shadow the planner object + this.#local.parse.monthDay = Object.create(classState.parse.monthDay); // shadow the monthDay object setProperty(this.#local.parse, 'result', [...(options.result ?? [])]); Object.defineProperty(this.#local, 'tempoInstance', { // Link this instance to its state for static alias access From 9e6beb67c86424090997fd5f1aea47a70b3fccd3 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 11:10:07 +1000 Subject: [PATCH 21/29] migration phase2.1 --- packages/tempo/plan/alias-migration-phase2.md | 49 ++++++++++ packages/tempo/src/discrete/discrete.parse.ts | 26 ++--- packages/tempo/src/engine/engine.alias.ts | 45 ++++++--- packages/tempo/src/support/tempo.default.ts | 2 +- .../test/core/alias-engine-protochain.test.ts | 22 ++--- .../tempo/test/core/alias-engine.mock.test.ts | 4 +- packages/tempo/test/core/alias-engine.test.ts | 96 +++++++++---------- .../tempo/test/core/constructor.core.test.ts | 2 +- packages/tempo/test/core/tempo_guard.test.ts | 6 +- .../parse.prefilter.numeric-safety.test.ts | 2 +- .../test/plugins/ticker.patterns.test.ts | 2 +- 11 files changed, 156 insertions(+), 100 deletions(-) create mode 100644 packages/tempo/plan/alias-migration-phase2.md diff --git a/packages/tempo/plan/alias-migration-phase2.md b/packages/tempo/plan/alias-migration-phase2.md new file mode 100644 index 0000000..4208925 --- /dev/null +++ b/packages/tempo/plan/alias-migration-phase2.md @@ -0,0 +1,49 @@ +# 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. + +- [ ] Create a `getResolutionContext(state, dateTime)` helper in `support` or `AliasEngine`. +- [ ] Ensure the context provides `add`, `subtract`, `with`, `set`, and time-unit accessors. +- [ ] 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. + +- [ ] **Standardize Paths**: Both static and functional aliases should trigger the "snap" path if they match `Match.clock`. +- [ ] **Fix Precision Leak**: Ensure that snapping to a clock-time clears ALL sub-second components (ms, us, ns) from the anchor. +- [ ] **Support High-Precision**: Update the snapping logic to support `hh:mm:ss.ffffff` patterns natively. +- [ ] **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. + +- [ ] 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 + } + ``` +- [ ] 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". + +- [ ] Refactor `parseGroups` in `discrete.parse.ts` to consume the new `AliasResult`. +- [ ] Remove manual string-splitting and mutation logic from the Parser. +- [ ] Leverage the `source` metadata from the result instead of manually parsing regex group names (like `evt1_0`). + +## 5. Lifecycle & Monitoring +- [ ] Implement `AliasEngine.getVersion()` or similar to allow `Tempo` class to detect registry changes without deep-cloning. +- [ ] 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/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index a0f5d9c..4171f19 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -400,33 +400,27 @@ const _ParseEngine = { config: state.config }; - const res = String(aliasEngine?.resolveAlias(key as any, host) ?? ''); - const isEvent = register.type === 'evt'; + const res = aliasEngine?.resolveAlias(key as any, host); + if (!res) continue; try { - const type = isEvent ? 'Event' : 'Period'; - const pat = isEvent ? 'dt' : 'tm'; - const depth = parseInt(key.match(/\d+/)?.[0] ?? '0'); - const source = depth === 0 ? 'global' : 'local'; + const type = res.type === 'evt' ? 'Event' : 'Period'; + const pat = res.type === 'evt' ? 'dt' : 'tm'; - _ParseEngine.result(state, { type, value: aliasKey as any, match: pat, source, groups: { [key]: res } }); + _ParseEngine.result(state, { type, value: res.key as any, match: pat, source: res.source, groups: { [key]: res.value } }); // If the alias resolved to a time-snap (hh:mm[:ss]), we handle it directly - if (isFn && Match.clock.test(res)) { - const [hourStr, minuteStr, secondStr = '0'] = res.split(':'); - const hour = Number(hourStr); - const minute = Number(minuteStr); - const second = Number(secondStr); - dateTime = dateTime.with({ hour, minute, second, millisecond: 0 }); + if (res.isClock) { + dateTime = dateTime.withPlainTime(Temporal.PlainTime.from(res.value)); } // Otherwise, if it resolved to a new string, we re-parse it - else if (!isEmpty(res) && res !== String(groups[key])) { + else if (!isEmpty(res.value) && res.value !== String(groups[key])) { const resolving = new Set(resolvingKeys); - resolving.add(aliasKey); + resolving.add(res.key); // Explicitly propagate anchor for recursive parse const prevAnchor: any = state.anchor; state.anchor = dateTime; - const resMatch = _ParseEngine.parseLayout(state, res, dateTime, true, resolving); + const resMatch = _ParseEngine.parseLayout(state, res.value, dateTime, true, resolving); state.anchor = prevAnchor; if (resMatch.type === 'Temporal.ZonedDateTime') diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 4000e1d..6e0adfa 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -16,7 +16,8 @@ import type { Nullable } from '#library/type.library.js'; import type { Logify } from '#library/logify.class.js'; -import { isDefined, isFunction } from '#library'; +import { isDefined, isFunction, isZonedDateTime } from '#library/assertion.library.js'; +import { Match } from '#tempo/support'; import { ownEntries } from '#library/primitive.library.js'; import * as t from '../tempo.type.js'; @@ -25,6 +26,15 @@ type AliasType = 'evt' | 'per'; type AliasKey = `${AliasType}${number}_${number}`; type State = Record +export interface AliasResult { + value: string; + key: string; // The original baseName (e.g. 'noon') + type: AliasType; + source: 'global' | 'local'; + isClock: boolean; + isFunction: boolean; +} + export interface AliasEngineOptions { parent?: Nullable; logger?: Nullable; @@ -37,6 +47,7 @@ interface Registry { // information about each registered ali type: AliasType; baseWord: string; collision?: boolean; + depth: number; } export class AliasEngine { @@ -116,6 +127,7 @@ export class AliasEngine { type, // 'evt' or 'per' baseWord, // used for collision detection collision, // needed ? + depth: this.#depth, } } @@ -128,14 +140,11 @@ export class AliasEngine { * it won't be included in the regex patterns of the parent engine, * preventing unintended matches and preserving the expected behavior of alias resolution. */ - getPatterns(type: AliasType, seenBaseNames = new Set()): string { + getPatterns(type: AliasType, seenBaseNames = new Set()): string | undefined { const patterns: string[] = []; - const state = this.#state; - for (const alias in state) { - const register = state[alias as AliasKey]; - - if (!seenBaseNames.has(register.baseWord)) { + for (const [alias, register] of ownEntries(this.#state)) { + if (register.type === type && !seenBaseNames.has(register.baseWord)) { seenBaseNames.add(register.baseWord); if (register.type === type) @@ -159,18 +168,28 @@ export class AliasEngine { : true } - resolveAlias(name: AliasKey, thisArg?: any): Nullable { + resolveAlias(name: AliasKey, thisArg?: any): AliasResult | undefined { const register = this.getAlias(name); - if (!register) return name; + if (!register) return undefined; + + let value = ''; + const isFn = isFunction(register.target); if (isFunction(register.target)) { const result = register.target.call(thisArg); - return isDefined(result) - ? result.toString() - : '' + value = isDefined(result) ? result.toString() : ''; + } else { + value = register.target.toString(); } - return register.target as string; + return { + value, + key: register.name, + type: register.type, + source: register.depth === 0 ? 'global' : 'local', + isClock: Match.clock.test(value), + isFunction: isFn + }; } getAlias(key: string): Registry | undefined { diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 52ae76a..2aa1261 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -23,7 +23,7 @@ export const Match = proxify({ /** two digit year */ twoDigit: /^[0-9]{2}$/, /** date (ISO 8601) */ date: /^(?:[+-][0-9]{6}|[0-9]{4})-?(?:0[1-9]|1[0-2])-?(?:0[1-9]|[12][0-9]|3[01])$/, /** time (HH:mm[:ss]) */ time: /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/, - /** clock (HH:mm[:ss]) */ clock: /^(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/, + /** clock (HH:mm[:ss][.ffffff]) */ clock: /^(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?(?:\.\d{1,9})?$/, /** separator characters (/ - . , T) */ separator: /[T\/\-\.\s,]/, /** modifier characters (+-<>=) */ modifier: /[\+\-\<\>][\=]?|this|next|prev|last/, /** offset post keywords (ago|hence) */ affix: /ago|hence|from now/, diff --git a/packages/tempo/test/core/alias-engine-protochain.test.ts b/packages/tempo/test/core/alias-engine-protochain.test.ts index 16f25ea..fd42d4f 100644 --- a/packages/tempo/test/core/alias-engine-protochain.test.ts +++ b/packages/tempo/test/core/alias-engine-protochain.test.ts @@ -29,20 +29,20 @@ describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => it('resolves local, sandbox, and global aliases in correct order', () => { // Local should resolve its own - expect(localShape.aliasEngine.resolveAlias('evt2_0')).toBe('localValue'); + expect(localShape.aliasEngine.resolveAlias('evt2_0')?.value).toBe('localValue'); // Local should resolve sandbox - expect(localShape.aliasEngine.resolveAlias('evt1_0')).toBe('sandboxValue'); + expect(localShape.aliasEngine.resolveAlias('evt1_0')?.value).toBe('sandboxValue'); // Local should resolve global - expect(localShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); + expect(localShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); // Sandbox should not see local - expect(sandboxShape.aliasEngine.resolveAlias('evt2_0')).toBe('evt2_0'); + expect(sandboxShape.aliasEngine.resolveAlias('evt2_0')).toBeUndefined(); // Sandbox should resolve its own and global - expect(sandboxShape.aliasEngine.resolveAlias('evt1_0')).toBe('sandboxValue'); - expect(sandboxShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); + expect(sandboxShape.aliasEngine.resolveAlias('evt1_0')?.value).toBe('sandboxValue'); + expect(sandboxShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); // Global should only resolve its own - expect(globalShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); - expect(globalShape.aliasEngine.resolveAlias('evt1_0')).toBe('evt1_0'); - expect(globalShape.aliasEngine.resolveAlias('evt2_0')).toBe('evt2_0'); + expect(globalShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); + expect(globalShape.aliasEngine.resolveAlias('evt1_0')).toBeUndefined(); + expect(globalShape.aliasEngine.resolveAlias('evt2_0')).toBeUndefined(); }); it('collision detection traverses the prototype chain', () => { @@ -55,8 +55,8 @@ describe('AliasEngine prototype chain (Global → Sandbox → Instance)', () => expect(logger.warn).toHaveBeenCalled(); expect((logger.warn as any).mock.calls[0][1]).toMatch(/Collision detected/i); - expect(localShape.aliasEngine.resolveAlias('evt2_1')).toBe('localShadow'); - expect(globalShape.aliasEngine.resolveAlias('evt0_0')).toBe('globalValue'); + expect(localShape.aliasEngine.resolveAlias('evt2_1')?.value).toBe('localShadow'); + expect(globalShape.aliasEngine.resolveAlias('evt0_0')?.value).toBe('globalValue'); (logger.warn as any).mockReset(); }); diff --git a/packages/tempo/test/core/alias-engine.mock.test.ts b/packages/tempo/test/core/alias-engine.mock.test.ts index 908ed9f..4841575 100644 --- a/packages/tempo/test/core/alias-engine.mock.test.ts +++ b/packages/tempo/test/core/alias-engine.mock.test.ts @@ -35,8 +35,8 @@ describe('AliasEngine', () => { const child = new AliasEngine({ parent: root }); root.registerAliases('evt', [ ['rootEvent', 'rootValue'] ]); child.registerAliases('evt', [ ['childEvent', 'childValue'] ]); - expect(child.resolveAlias('evt0_0')).toBe('rootValue'); - expect(child.resolveAlias('evt1_0')).toBe('childValue'); + expect(child.resolveAlias('evt0_0')?.value).toBe('rootValue'); + expect(child.resolveAlias('evt1_0')?.value).toBe('childValue'); }); it('clears aliases correctly', () => { diff --git a/packages/tempo/test/core/alias-engine.test.ts b/packages/tempo/test/core/alias-engine.test.ts index b03f375..621b921 100644 --- a/packages/tempo/test/core/alias-engine.test.ts +++ b/packages/tempo/test/core/alias-engine.test.ts @@ -3,7 +3,7 @@ import type { Logify } from '#library/logify.class.js'; // Use a real Logify logger, but spy on console.warn const logger = { - warn: (...args: any[]) => console.warn(...args), + warn: (config: any, msg: string) => console.warn(msg, config), debug: () => { }, error: () => { }, log: () => { }, @@ -15,9 +15,10 @@ describe('AliasEngine', () => { it('registers and resolves string and function aliases', () => { const engine = new AliasEngine({ logger }); engine.registerAliases('evt', [['foo', 'bar']]); - expect(engine.resolveAlias('evt0_0')).toBe('bar'); + expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); engine.registerAliases('per', [['noon', function () { return '12:00'; }]]); - expect(engine.resolveAlias('per0_0')).toBe('12:00'); + expect(engine.resolveAlias('per0_0')?.value).toBe('12:00'); + expect(engine.resolveAlias('per0_0')?.isClock).toBe(true); }); it('supports parent/child shadowing and fallback', () => { @@ -25,68 +26,70 @@ describe('AliasEngine', () => { globalEngine.registerAliases('evt', [['foo', 'bar']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); // Local should resolve parent's alias before shadowing - expect(localEngine.resolveAlias('evt0_0')).toBe('bar'); + expect(localEngine.resolveAlias('evt0_0')?.value).toBe('bar'); + expect(localEngine.resolveAlias('evt0_0')?.source).toBe('global'); // After shadowing, local resolves its own, parent still resolves its own localEngine.registerAliases('evt', [['foo', 'baz']]); - expect(localEngine.resolveAlias('evt1_0')).toBe('baz'); - expect(globalEngine.resolveAlias('evt0_0')).toBe('bar'); + expect(localEngine.resolveAlias('evt1_0')?.value).toBe('baz'); + expect(localEngine.resolveAlias('evt1_0')?.source).toBe('local'); + expect(globalEngine.resolveAlias('evt0_0')?.value).toBe('bar'); }); it('warns on local/global collision', () => { + const warnSpy = vi.spyOn(console, 'warn'); const globalEngine = new AliasEngine({ logger }); - globalEngine.registerAliases('per', [['noon', '12:00']]); + globalEngine.registerAliases('evt', [['xmas', '25-Dec']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); - localEngine.registerAliases('per', [['noon', '11:00']]); - const warnCall = (console.warn as any).mock.calls[0]; - expect(warnCall[1]).toContain('noon'); + localEngine.registerAliases('evt', [['xmas', '24-Dec']]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + warnSpy.mockRestore(); }); it('warns on local collision', () => { + const warnSpy = vi.spyOn(console, 'warn'); const engine = new AliasEngine({ logger }); - engine.registerAliases('evt', [['foo', 'bar']]); - engine.registerAliases('evt', [['foo', 'baz']]); - const warnCall = (console.warn as any).mock.calls[0]; - expect(warnCall[1]).toContain('foo'); + engine.registerAliases('evt', [['xmas', '25-Dec'], ['xmas', '24-Dec']]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + warnSpy.mockRestore(); }); it('registers and resolves batch aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerAliases('evt', [ - ['a', '1'], - ['b', '2'], - ]); - expect(engine.resolveAlias('evt0_0')).toBe('1'); - expect(engine.resolveAlias('evt0_1')).toBe('2'); + engine.registerAliases('evt', [['foo', 'bar'], ['baz', 'qux']]); + expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); + expect(engine.resolveAlias('evt0_1')?.value).toBe('qux'); }); it('clears only events or periods', () => { const engine = new AliasEngine({ logger }); engine.registerAliases('evt', [['foo', 'bar']]); engine.registerAliases('per', [['noon', '12:00']]); + expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); + expect(engine.resolveAlias('per0_0')?.value).toBe('12:00'); + engine.clear('evt'); - // After clearing, the alias key should not resolve to the value, but to the key itself - expect(engine.resolveAlias('evt0_0')).toBe('evt0_0'); - expect(engine.resolveAlias('per0_0')).toBe('12:00'); + // After clearing, the alias key should not resolve + expect(engine.resolveAlias('evt0_0')).toBeUndefined(); + expect(engine.resolveAlias('per0_0')?.value).toBe('12:00'); engine.clear('per'); - expect(engine.resolveAlias('per0_0')).toBe('per0_0'); + expect(engine.resolveAlias('per0_0')).toBeUndefined(); }); it('handles regex-like collision heuristics', () => { - const globalEngine = new AliasEngine({ logger }); - globalEngine.registerAliases('per', [['noon', '12:00']]); - const localEngine = new AliasEngine({ parent: globalEngine, logger }); - localEngine.registerAliases('per', [['([after[ -]?])?noon', '11:00']]); - - // This should warn, even if not a perfect regex match - const warnCall = (console.warn as any).mock.calls[0]; - expect(warnCall[1]).toContain('noon'); + const warnSpy = vi.spyOn(console, 'warn'); + const engine = new AliasEngine({ logger }); + engine.registerAliases('evt', [['xmas( )?eve', '24-Dec'], ['xmas eve', '24-Dec']]); + // Should treat "xmas eve" and "xmas( )?eve" as same base word + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Collision detected'), undefined); + warnSpy.mockRestore(); }); it('does not warn on non-colliding aliases', () => { + const warnSpy = vi.spyOn(console, 'warn'); const engine = new AliasEngine({ logger }); - engine.registerAliases('evt', [['foo', 'bar']]); - engine.registerAliases('evt', [['baz', 'qux']]); - expect(console.warn).not.toHaveBeenCalled(); + engine.registerAliases('evt', [['xmas', '25-Dec'], ['bday', '20-May']]); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); }); it('resolves to parent after clear', () => { @@ -94,27 +97,18 @@ describe('AliasEngine', () => { globalEngine.registerAliases('evt', [['foo', 'bar']]); const localEngine = new AliasEngine({ parent: globalEngine, logger }); localEngine.registerAliases('evt', [['foo', 'baz']]); - expect(localEngine.resolveAlias('evt1_0')).toBe('baz'); - - let globalAliases = globalEngine.getAliases('evt'); - let localAliases = localEngine.getAliases('evt'); - expect(localAliases[0].target).toBe('baz'); + expect(localEngine.resolveAlias('evt1_0')?.value).toBe('baz'); localEngine.clear('evt'); - globalAliases = globalEngine.getAliases('evt'); - localAliases = localEngine.getAliases('evt'); - expect(globalAliases[0].target).toBe('bar'); - expect(localAliases.length).toBe(0); - // After clearing, local should fallback to parent, but since the key is unique per depth, fallback is not automatic - // expect(localEngine.resolveAlias('evt1_0')).toBe('baz'); - expect(localEngine.resolveAlias('evt0_0')).toBe('bar'); + // Should resolve back to parent's alias after clearing local + expect(localEngine.resolveAlias('evt0_0')?.value).toBe('bar'); }); it('handles empty/optional/edge-case aliases', () => { const engine = new AliasEngine({ logger }); - engine.registerAliases('evt', [['', 'empty']]); - expect(engine.resolveAlias('evt0_0')).toBe('empty'); - engine.registerAliases('evt', [['?', 'question']]); - expect(engine.resolveAlias('evt0_1')).toBe('question'); + engine.registerAliases('evt', [['', 'empty'], ['foo', '']]); + expect(engine.resolveAlias('evt0_0')?.value).toBe('empty'); + expect(engine.resolveAlias('evt0_1')?.value).toBe(''); + expect(engine.resolveAlias('non-existent' as any)).toBeUndefined(); }); }); diff --git a/packages/tempo/test/core/constructor.core.test.ts b/packages/tempo/test/core/constructor.core.test.ts index e22a01b..91f8919 100644 --- a/packages/tempo/test/core/constructor.core.test.ts +++ b/packages/tempo/test/core/constructor.core.test.ts @@ -22,7 +22,7 @@ describe('Tempo Core', () => { it('should fail-fast (strict) if input fails Master Guard', () => { // 'Hello World' fails the guard, so it attempts immediate parsing and throws - expect(() => new Tempo('Hello World')).toThrow(/Cannot parse Date/); + expect(() => new Tempo('Hello World')).toThrow(/invalid ISO 8601 string/); }); }); diff --git a/packages/tempo/test/core/tempo_guard.test.ts b/packages/tempo/test/core/tempo_guard.test.ts index 04a63e7..76ad331 100644 --- a/packages/tempo/test/core/tempo_guard.test.ts +++ b/packages/tempo/test/core/tempo_guard.test.ts @@ -7,7 +7,7 @@ describe('Master Guard Extension', () => { it('should rebuild the guard after extension via Discovery', () => { // 1. Initially, '$$$apple$$$' should FAIL the guard and throw immediately - expect(() => new Tempo('$$$apple$$$')).toThrow(/Cannot parse Date: "\$\$\$apple\$\$\$"/); + expect(() => new Tempo('$$$apple$$$')).toThrow(/Unrecognized or invalid ISO 8601 string: \"\$\$\$apple\$\$\$\"/); // 2. Extend with a custom term '$$$apple$$$' via Discovery object Tempo.extend({ @@ -23,12 +23,12 @@ describe('Master Guard Extension', () => { // expect(t.parse.lazy).toBe(true); // 4. Accessing a property should now trigger parsing and throw - expect(() => t.yy).toThrow(/Cannot parse Date: "\$\$\$apple\$\$\$"/); + expect(() => t.yy).toThrow(/Unrecognized or invalid ISO 8601 string: \"\$\$\$apple\$\$\$\"/); }); it('should rebuild the guard after direct extension', () => { // 1. '@@@banana@@@' fails initially - expect(() => new Tempo('@@@banana@@@')).toThrow(/Cannot parse Date: "@@@banana@@@"/); + expect(() => new Tempo('@@@banana@@@')).toThrow(/Unrecognized or invalid ISO 8601 string: \"@@@banana@@@\"/); // 2. Extend directly Tempo.extend({ 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 46f1505..dc0a5de 100644 --- a/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts +++ b/packages/tempo/test/engine/parse.prefilter.numeric-safety.test.ts @@ -7,7 +7,7 @@ describe('parse prefilter numeric safety constraints', () => { test('keeps integer-like BigInt nanosecond string as early escape', () => { expect(() => new Tempo('1715900000000000000n', { timeZone: 'UTC' })) - .toThrow(/Cannot parse Date/i); + .toThrow(/Unrecognized or invalid ISO 8601 string: \"1715900000000000000n\"/i); }); test('number input with less than 8 digits still rejects safely', () => { diff --git a/packages/tempo/test/plugins/ticker.patterns.test.ts b/packages/tempo/test/plugins/ticker.patterns.test.ts index b6f55d0..4c2c3db 100644 --- a/packages/tempo/test/plugins/ticker.patterns.test.ts +++ b/packages/tempo/test/plugins/ticker.patterns.test.ts @@ -114,7 +114,7 @@ describe(`${label}`, () => { // @ts-ignore expect(() => Tempo.ticker(Infinity)).toThrow(/Invalid Tempo number: Infinity/); // @ts-ignore - expect(() => Tempo.ticker('not a number')).toThrow(/Cannot parse Date: "not a number"/); + expect(() => Tempo.ticker('not a number')).toThrow(/Unrecognized or invalid ISO 8601 string: "not a number"/); }); }); From 40545f899db351bde4ad219014fa93eb83cb3694 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 11:35:28 +1000 Subject: [PATCH 22/29] ready for AliasMigration review --- packages/tempo/.vitepress/config.ts | 3 +- packages/tempo/plan/alias-migration-phase2.md | 30 +++++----- packages/tempo/src/discrete/discrete.parse.ts | 59 ++++++++++--------- packages/tempo/src/engine/engine.alias.ts | 14 ++++- packages/tempo/src/tempo.class.ts | 44 ++++++++++---- 5 files changed, 91 insertions(+), 59 deletions(-) diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 82ad0fd..ea0fc9f 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -12,7 +12,8 @@ export default defineConfig({ base: '/magma/', title: "Tempo", description: "The Professional Date-Time Library for Temporal", - srcDir: './doc', + srcDir: '.', + srcExclude: ['**/plan/**', '**/archive/**', '**/bench/**', '**/scratch/**', 'CHANGELOG.md', 'CONTRIBUTING.md'], markdown: { math: true }, diff --git a/packages/tempo/plan/alias-migration-phase2.md b/packages/tempo/plan/alias-migration-phase2.md index 4208925..d506b06 100644 --- a/packages/tempo/plan/alias-migration-phase2.md +++ b/packages/tempo/plan/alias-migration-phase2.md @@ -5,22 +5,22 @@ This document outlines the remaining tasks to complete the migration from legacy ## 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. -- [ ] Create a `getResolutionContext(state, dateTime)` helper in `support` or `AliasEngine`. -- [ ] Ensure the context provides `add`, `subtract`, `with`, `set`, and time-unit accessors. -- [ ] Remove the manual host construction from `discrete.parse.ts`. +- [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. -- [ ] **Standardize Paths**: Both static and functional aliases should trigger the "snap" path if they match `Match.clock`. -- [ ] **Fix Precision Leak**: Ensure that snapping to a clock-time clears ALL sub-second components (ms, us, ns) from the anchor. -- [ ] **Support High-Precision**: Update the snapping logic to support `hh:mm:ss.ffffff` patterns natively. -- [ ] **Engine-Level Detection**: Move the `Match.clock` test into `AliasEngine.resolveAlias`. +- [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. -- [ ] Define `AliasResult` interface: +- [x] Define `AliasResult` interface: ```typescript interface AliasResult { value: string; @@ -28,20 +28,22 @@ Instead of returning a raw `string | number`, the `AliasEngine` should return a type: 'evt' | 'per'; source: 'global' | 'local'; isClock: boolean; // True if it matched Match.clock + isFunction: boolean; } ``` -- [ ] Update `resolveAlias` to return this structure. +- [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". -- [ ] Refactor `parseGroups` in `discrete.parse.ts` to consume the new `AliasResult`. -- [ ] Remove manual string-splitting and mutation logic from the Parser. -- [ ] Leverage the `source` metadata from the result instead of manually parsing regex group names (like `evt1_0`). +- [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 -- [ ] Implement `AliasEngine.getVersion()` or similar to allow `Tempo` class to detect registry changes without deep-cloning. -- [ ] Audit `tempo.class.ts` for any remaining direct access to `parse.event` or `parse.period`. +- [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`. --- diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 4171f19..8dc5da0 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -21,6 +21,33 @@ import enums from '../support/tempo.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 */ @@ -377,29 +404,7 @@ const _ParseEngine = { resolvingKeys.add(aliasKey); - const isFn = isFunction(register.target); - - // Provide a lightweight host context that mimics a Tempo instance for the handler - const host = { - 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 - }; - + const host = getResolutionContext(state, dateTime, resolvingKeys); const res = aliasEngine?.resolveAlias(key as any, host); if (!res) continue; @@ -409,12 +414,8 @@ const _ParseEngine = { _ParseEngine.result(state, { type, value: res.key as any, match: pat, source: res.source, groups: { [key]: res.value } }); - // If the alias resolved to a time-snap (hh:mm[:ss]), we handle it directly - if (res.isClock) { - dateTime = dateTime.withPlainTime(Temporal.PlainTime.from(res.value)); - } - // Otherwise, if it resolved to a new string, we re-parse it - else if (!isEmpty(res.value) && res.value !== String(groups[key])) { + // 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 diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 6e0adfa..0ee8d2a 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -71,6 +71,7 @@ export class AliasEngine { #state: State; // object that holds alias mappings, collisions, and registry for this engine #words: Record; // object of base words for collision detection #id: number; + #version = 0; get depth() { return this.#depth @@ -131,6 +132,7 @@ export class AliasEngine { } } + this.#version++; return this.getPatterns(type); } @@ -160,6 +162,10 @@ export class AliasEngine { return patterns.join('|'); } + getVersion(): number { + return this.#version + (this.#parent?.getVersion() ?? 0); + } + hasAlias(name: string, type?: AliasType) { return !(name in this.#state) ? false @@ -196,15 +202,18 @@ export class AliasEngine { return this.#state[key as AliasKey] ?? this.#parent?.getAlias(key); } - getAliases(type?: AliasType) { + getAliases(type?: AliasType, recurse = false) { const aliases = [] as Registry[]; - ownEntries(this.#state) // just the entries at this depth + ownEntries(this.#state) .filter(([_, register]) => !type || register.type === type) .forEach(([key, register]) => { aliases.push(Object.assign({}, { key }, register)); }); + if (recurse && this.#parent) + aliases.push(...this.#parent.getAliases(type, true)); + return aliases; } @@ -216,6 +225,7 @@ export class AliasEngine { delete this.#state[registry as AliasKey]; } } + this.#version++; } } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index be37d27..c6433cd 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -141,15 +141,22 @@ export class Tempo { * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event */ // TODO: check all Layouts which reference "{evt}" and update them - static [$setEvents](shape: Internal.State) { + static [$setEvents](shape: Internal.State, provided?: [string, any][]) { const parent = proto(shape); const parentEvents = parent.parse?.event ?? {}; // Identify local additions or overrides - const events = ownEntries(shape.parse.event, true).filter(([k, v]) => { + const events = provided ?? ownEntries(shape.parse.event, true).filter(([k, v]) => { return !(k in parentEvents) || shape.parse.event[k as string] !== parentEvents[k as string]; }); + // Sync legacy registry if provided directly + if (provided) { + provided.forEach(([k, v]) => { + if (!hasOwn(shape.parse.event, k)) shape.parse.event[k as string] = v; + }); + } + // If no local events, inherit the parent's engine (via prototype) and exit if (events.length === 0 && !hasOwn(shape, 'aliasEngine')) return; @@ -197,15 +204,22 @@ export class Tempo { * because it will also include a list of periods (e.g. 'midnight' | 'afternoon' ), we need to rebuild {tm} if the user adds a new period */ // TODO: check all Layouts which reference "{per}" and update them - static [$setPeriods](shape: Internal.State) { + static [$setPeriods](shape: Internal.State, provided?: [string, any][]) { const parent = proto(shape); const parentPeriods = parent.parse?.period ?? {}; // Identify local additions or overrides - const periods = ownEntries(shape.parse.period, true).filter(([k, v]) => { + const periods = provided ?? ownEntries(shape.parse.period, true).filter(([k, v]) => { return !(k in parentPeriods) || shape.parse.period[k as string] !== parentPeriods[k as string]; }); + // Sync legacy registry if provided directly + if (provided) { + provided.forEach(([k, v]) => { + if (!hasOwn(shape.parse.period, k)) shape.parse.period[k as string] = v; + }); + } + // If no local periods, inherit the parent's engine (via prototype) and exit if (periods.length === 0 && !hasOwn(shape, 'aliasEngine')) return; @@ -468,8 +482,7 @@ export class Tempo { ...Object.keys(enums.DURATION), ...Object.keys(enums.DURATIONS), ...Object.keys(enums.TIMEZONE), - ...ownKeys((this as any)[$Internal]().parse.event), - ...ownKeys((this as any)[$Internal]().parse.period), + ...((this as any)[$Internal]().aliasEngine?.getAliases(undefined, true).map((a: any) => a.name) ?? []), ...ownKeys((this as any)[$Internal]().parse.ignore), ...ownKeys((this as any)[$Internal]().parse.snippet), ...ownKeys((this as any)[$Internal]().parse.layout), @@ -624,18 +637,23 @@ export class Tempo { registerTerm(config); - // 3. sync with parser registries + // 1a. sync with alias engine if (config.scope && config.ranges) { - const target = config.scope === 'period' ? (this as any)[sym.$Internal]().parse.period : (config.scope === 'event' ? (this as any)[sym.$Internal]().parse.event : undefined); - if (target) { + const type = config.scope === 'period' ? 'per' : (config.scope === 'event' ? 'evt' : undefined); + if (type) { + const aliases: [string, any][] = []; config.ranges.forEach(r => { - if (r.key && !target[r.key]) { + if (r.key) { const val = isDefined(r.hour) ? `${r.hour}:${pad(r.minute ?? 0)}` : (r.month ? `${pad(r.day ?? 1)} ${Tempo.MONTH.keys()[r.month - 1]}` : undefined); - if (val) target[r.key] = val; + if (val) aliases.push([r.key, val]); } }); - if (config.scope === 'period') (this as any)[$setPeriods]((this as any)[sym.$Internal]()); - if (config.scope === 'event') (this as any)[$setEvents]((this as any)[sym.$Internal]()); + + if (aliases.length > 0) { + const state = (this as any)[sym.$Internal](); + if (type === 'per') (this as any)[$setPeriods](state, aliases); + else if (type === 'evt') (this as any)[$setEvents](state, aliases); + } } } } From 6f5276c26a353ab4f63c87e29a6a68b8526e1a6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 01:42:56 +0000 Subject: [PATCH 23/29] Merge origin/main into release/D: resolve conflicts keeping release/D version Agent-Logs-Url: https://github.com/magmacomputing/magma/sessions/984a79e1-3587-4d46-a02c-2e09df728e9e Co-authored-by: magmacomputing <6935496+magmacomputing@users.noreply.github.com> --- package-lock.json | 49 +++++------------------------------------------ 1 file changed, 5 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index 568f75e..6e26a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "2.8.0", + "version": "2.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "2.8.0", + "version": "2.9.1", "workspaces": [ "packages/*" ], @@ -1058,9 +1058,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1075,9 +1072,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1092,9 +1086,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1109,9 +1100,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1126,9 +1114,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1143,9 +1128,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1160,9 +1142,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1177,9 +1156,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1194,9 +1170,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1211,9 +1184,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1228,9 +1198,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1245,9 +1212,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1262,9 +1226,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4834,7 +4795,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "2.8.0", + "version": "2.9.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -4853,14 +4814,14 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "2.8.0", + "version": "2.9.1", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "2.8.0", + "@magmacomputing/library": "2.9.1", "@rollup/plugin-alias": "^6.0.0", "cross-env": "^7.0.3", "magic-string": "^0.30.21", From ec9e905ada23b94c4a56691cf0dd38406b944651 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 12:49:17 +1000 Subject: [PATCH 24/29] pre collapse alias --- packages/tempo/CHANGELOG.md | 16 ++++++++++ packages/tempo/README.md | 1 + packages/tempo/doc/architecture.md | 14 +++++++- packages/tempo/doc/releases/v2.x.md | 17 ++++++++++ packages/tempo/doc/tempo.modularity.md | 2 +- packages/tempo/doc/tempo.parse.md | 32 ++++++++++++++++++- packages/tempo/doc/tempo.term.md | 19 +++++++---- packages/tempo/package.json | 14 ++------ packages/tempo/src/discrete/discrete.parse.ts | 2 +- packages/tempo/src/plugin/term.util.ts | 14 +++++--- .../tempo/src/plugin/term/standard.index.ts | 11 ------- packages/tempo/src/plugin/term/term.index.ts | 11 +++++-- packages/tempo/src/tempo.class.ts | 5 +-- .../test/plugins/fiscal-cycle.core.test.ts | 2 +- .../test/plugins/term-dispatch.core.test.ts | 2 +- .../test/plugins/ticker.term.core.test.ts | 2 +- packages/tempo/vitest.config.ts | 2 -- 17 files changed, 119 insertions(+), 47 deletions(-) delete mode 100644 packages/tempo/src/plugin/term/standard.index.ts diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 13a7081..10d538a 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -4,6 +4,22 @@ 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.0] - 2026-05-06 + +### Added +- **Centralized Alias Architecture**: Finalized the migration to a unified `AliasEngine`. All event and period aliases are now managed through a centralized registry, providing a single source of truth across global and local contexts. +- **Rich Alias Results**: Alias resolution now returns a structured `AliasResult` object containing exhaustive metadata, including the source (global/local), type (Event/Period), and specific resolution flags. +- **Hardened Clock Snapping**: Standardized the resolution path for clock-like aliases (e.g. `8:00`). The engine now ensures absolute sub-second precision clearing (milliseconds, microseconds, and nanoseconds) when snapping to a time-string alias. +- **Optimized Lifecycle Monitoring**: Implemented a version counter in the `AliasEngine`. Mutation operations now trigger a version increment, allowing `Tempo` instances to efficiently detect registry changes and rebuild internal regex patterns without expensive deep-cloning. + +### Changed +- **Parser Context Consolidation**: Extracted the "host" facade construction from the main parsing loop into a dedicated `getResolutionContext` helper, improving maintainability and reducing Parser complexity. +- **Decoupled Term Registration**: Refactored `Tempo.extend` and term-based alias registration to bypass legacy raw registries, while maintaining backward compatibility via a mirrored metadata view. + +### Fixed +- **Documentation Server Stability**: Resolved VitePress 404 errors by correcting the `srcDir` configuration and implemented `srcExclude` to prevent build failures from dead links in non-documentation folders. + ## [2.8.0] - 2026-04-30 diff --git a/packages/tempo/README.md b/packages/tempo/README.md index 0ab63a1..0aa0529 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -90,6 +90,7 @@ For granular "Lite" builds, see the [Full Installation Guide](https://magmacompu ## ✨ Why Tempo? * **🏗️ Future Standard**: Built natively on the TC39 `Temporal` proposal. Inherit the reliability of the future standard. * **🗣️ Natural Language**: Resolve complex terms like `#quarter.last` or "two days ago" with zero configuration. +* **🧠 Functional Aliases**: Extend the parser with custom logic using a powerful resolution context for relative date math. * **🔄 Cycle Persistence**: Shift by semantic terms (Quarters, Seasons) while preserving your relative day-of-period offset. * **⚡ Zero-Cost Parsing**: Lazy evaluation and smart matching ensure instantiation overhead is near-zero. * **🛡️ Monorepo Resilient**: Built for stability in complex environments with proxy-protected registries. diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index 2f3ebdc..ff377c6 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -130,7 +130,19 @@ The **Guarded-Lazy** strategy ensures that even with hundreds of custom plugins, 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. 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. **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. +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. +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. + +--- + +## 🧩 Centralized Alias Management (v2.9.0) +As of **v2.9.0**, Tempo has consolidated all Event and Period alias logic into a dedicated **`AliasEngine`**. + +### How it works: +- **Hierarchical Registry**: Aliases are managed in a prototype-aware chain. A local `Tempo` instance can have its own private aliases that shadow global ones, all while sharing the same underlying resolution logic. +- **Rich Metadata**: Every resolution returns a structured `AliasResult`, providing the Parser with immediate knowledge of the alias's origin (global vs local), type, and clock-snapping requirements. +- **Clock Snapping**: Time-based aliases (e.g. `8:00`) are automatically "snapped" to absolute precision, clearing sub-second drift (ms, us, ns) during the resolution phase. +- **Decoupled Registration**: By moving away from legacy raw objects, the registry is now protected against accidental mutation and supports efficient, version-aware monitoring. ### 📈 Validation & Performance The efficiency of the Master Guard and the success of the Zero-Cost objective have been validated via local benchmarking: diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index f1c2831..3489b6b 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,6 +1,23 @@ # 📜 Version 2.x History +## [v2.9.0] - 2026-05-06 +### 🏗️ Alias Architecture Stabilization +- **Unified Alias Engine**: Completed the architectural migration to the `AliasEngine`, centralizing all registration and resolution logic for Events and Periods. +- **Rich Metadata Results**: Enhanced the resolution engine to return structured `AliasResult` objects, providing better traceability and granular control over alias-driven parses. +- **Hardened Clock Snapping**: Finalized the standardization of clock-like alias resolution, ensuring consistent sub-second precision clearing for all static and functional time aliases. +- **Performance Optimized Monitoring**: Introduced a lightweight version-tracking system to detect registry mutations, enabling `O(1)` change detection during the Tempo lifecycle. + +### ⚙️ Parser & Registry Refinement +- **Consolidated Host Context**: Modularized the construction of the functional alias "pseudo-Tempo" context, improving separation of concerns in the parsing pipeline. +- **Legacy Decoupling**: Successfully decoupled term-based alias registration from internal raw objects, moving toward a more encapsulated and secure registry design. + +### 📚 Documentation Fixes +- **VitePress 404 Patch**: Corrected root directory resolution to fix "404 Not Found" errors on the landing page. +- **Build Resilience**: Implemented targeted folder exclusion to prevent build-time dead-link errors in non-public directories. + +--- + ## [v2.8.0] - 2026-04-30 ### 🚨 Immutability System Refined - The project evaluated mutation-throwing Proxies for all immutable objects, but reverted to using `Object.freeze` for stability and compatibility. diff --git a/packages/tempo/doc/tempo.modularity.md b/packages/tempo/doc/tempo.modularity.md index 07a2065..7699644 100644 --- a/packages/tempo/doc/tempo.modularity.md +++ b/packages/tempo/doc/tempo.modularity.md @@ -57,7 +57,7 @@ Adds support for semantic terms like `qtr`, `szn`, `zdc`, and `per`. There are t #### 1. The Side-Effect (Standard Activation) Fastest way to enable all standard terms in a Core environment. ```typescript -import '@magmacomputing/tempo/term/standard'; // One-line activation +import '@magmacomputing/tempo/term'; // One-line activation ``` #### 2. The Explicit Module (Uniform Sync) diff --git a/packages/tempo/doc/tempo.parse.md b/packages/tempo/doc/tempo.parse.md index f6a814e..d4ec7c8 100644 --- a/packages/tempo/doc/tempo.parse.md +++ b/packages/tempo/doc/tempo.parse.md @@ -111,7 +111,37 @@ Tempo.init({ const t = new Tempo('party'); ``` +### 🧠 Functional Alias Context +When you use a function as an alias value, Tempo provides a powerful **Resolution Context** (the `this` binding). This context mimics a lightweight Tempo instance, allowing you to perform relative date math during resolution. + +Available methods in the context: +* **`this.add(duration)`**: Add a duration to the current anchor. +* **`this.subtract(duration)`**: Subtract a duration. +* **`this.with(values)`**: Set specific fields (year, month, day, etc.). +* **`this.set(input)`**: Recursively parse another string or value relative to the anchor. +* **`this.toNow()`**: Get the current system time. +* **`this.toDateTime()`**: Get the current anchor as a native `Temporal.ZonedDateTime`. +* **`this.hh`, `this.mi`, `this.ss`**: Accessors for current time units. + +#### Example: Complex Functional Alias +```typescript +Tempo.init({ + event: { + // Resolve "bedtime" to 10pm on the same day + 'bedtime': function() { + return this.with({ hour: 22, minute: 0, second: 0 }); + }, + // Resolve "meeting" to 2 hours after whatever was just parsed + 'meeting': function() { + return this.add({ hours: 2 }); + } + } +}); +``` + --- ## 🛡️ Performance: The Master Guard -Tempo uses a "Scan-and-Consume" engine called the **Master Guard**. This allows it to check your input string against dozens of patterns (weekdays, months, custom events) in a single pass, ensuring that parsing remains $O(1)$ relative to the number of plugins you have active. +Tempo uses a "Scan-and-Consume" engine called the **Master Guard**. This allows it to check your input string against dozens of patterns (weekdays, months, custom events) in a single pass. + +In version **2.9.0**, the Master Guard has been optimized with a **Versioned Registry**. The engine now tracks a `#version` counter on the alias registry, ensuring that the guard's pattern list is only rebuilt when a mutation actually occurs. This provides near-instant validation for high-volume parsing tasks. diff --git a/packages/tempo/doc/tempo.term.md b/packages/tempo/doc/tempo.term.md index e1a2bf6..59c28db 100644 --- a/packages/tempo/doc/tempo.term.md +++ b/packages/tempo/doc/tempo.term.md @@ -42,7 +42,7 @@ Hemisphere-aware: southern-hemisphere configs shift the quarter boundaries by si const t = new Tempo('15-Feb-2025'); t.term.qtr // → 'Q1' -t.term.quarter // → { key: 'Q1', day: 1, month: 1, fiscal: 2025, sphere: 'North' } +t.term.quarter // → { key: 'Q1', day: 1, month: 1, fiscal: 2025, sphere: 'north' } ``` ```ts @@ -54,17 +54,14 @@ t.term.qtr // → 'Q3' (southern hemisphere) ### `szn` / `season` — Meteorological Seasons Maps the current date to the appropriate meteorological season. -Hemisphere-aware (northern / southern boundaries differ), and the full `season` scope additionally includes the corresponding **Chinese season** for the date. +Hemisphere-aware (northern / southern boundaries differ). ```ts const t = new Tempo('01-Jul-2025'); t.term.szn // → 'Winter' (northern hemisphere) t.term.season -// → { key: 'Winter', day: 22, month: 12, symbol: 'Snowflake', sphere: 'North' } - -t.term.season.CN -// → { key: 'Summer', symbol: 'Sun', ... } +// → { key: 'Winter', day: 22, month: 12, symbol: 'Snowflake', sphere: 'north' } ``` ```ts @@ -132,7 +129,7 @@ In **Tempo Full**, all standard terms are enabled by default. In **Tempo Core**, ### 1. Standard Activation (Recommended) The fastest way to enable all built-in terms (`qtr`, `szn`, `zdc`, `per`). ```typescript -import '@magmacomputing/tempo/term/standard'; // One-line side-effect activation +import '@magmacomputing/tempo/term'; // One-line side-effect activation ``` ### 2. Explicit Module (Uniform Sync) @@ -210,6 +207,14 @@ export const MySeasonTerm = defineTerm({ A `Range` object must include a `key` and any subset of the date-time fields below. `getTermRange` sorts ranges in descending chronological order and returns the **first range whose boundary the instance has reached or passed**. +### 🔄 Sync to Alias Engine +When a term plugin defines `ranges` with string-based `key` values, Tempo automatically synchronizes these keys with the internal **Alias Engine**. + +* **Period Scopes**: Ranges defined in a `period` scope (like `midnight` or `morning`) are registered as **Period Aliases**. +* **Event Scopes**: Ranges defined in an `event` scope are registered as **Event Aliases**. + +This synchronization happens during `Tempo.extend()` and `Tempo.init()`, ensuring that any named range boundaries are immediately available for use in the natural-language parsing engine. For example, if you define a custom term with a range key `"bedtime"`, you can immediately create a new instance using `new Tempo('bedtime')`. + ```ts type Range = { key: PropertyKey; // identifier returned when keyOnly = true diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 374ed6d..bf436df 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -24,8 +24,8 @@ "type": "module", "sideEffects": [ "**/tempo.index.js", - "**/plugin/term/standard.index.js", - "**/plugin/term/standard.index.ts", + "**/plugin/term/term.index.js", + "**/plugin/term/term.index.ts", "**/plugin/extend/extend.*.js", "**/plugin/extend/extend.*.ts", "dist/engine/engine.*.js", @@ -69,10 +69,6 @@ "development": "./src/plugin/extend/extend.ticker.ts", "default": "./dist/plugin/extend/extend.ticker.js" }, - "#tempo/term/standard": { - "development": "./src/plugin/term/standard.index.ts", - "default": "./dist/plugin/term/standard.index.js" - }, "#tempo/term": { "development": "./src/plugin/term/term.index.ts", "default": "./dist/plugin/term/term.index.js" @@ -147,10 +143,6 @@ "types": "./dist/module/module.*.d.ts", "import": "./dist/module/module.*.js" }, - "./term/standard": { - "types": "./dist/plugin/term/standard.index.d.ts", - "import": "./dist/plugin/term/standard.index.js" - }, "./term/*": { "types": "./dist/plugin/term/term.*.d.ts", "import": "./dist/plugin/term/term.*.js" @@ -252,4 +244,4 @@ "doc": "doc", "test": "test" } -} +} \ No newline at end of file diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 8dc5da0..983e2da 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -1,6 +1,6 @@ import '#library/temporal.polyfill.js'; import { asType } from '#library/type.library.js'; -import { isNull, isString, isObject, isFunction, isZonedDateTime, isInstant, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/assertion.library.js'; +import { isNull, isString, isObject, isZonedDateTime, isInstant, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/assertion.library.js'; import { asArray, asInteger } from '#library/coercion.library.js'; import { isNumeric } from '#library/assertion.library.js'; import { instant, getTemporalIds } from '#library/temporal.library.js'; diff --git a/packages/tempo/src/plugin/term.util.ts b/packages/tempo/src/plugin/term.util.ts index cccfffe..c44f88b 100644 --- a/packages/tempo/src/plugin/term.util.ts +++ b/packages/tempo/src/plugin/term.util.ts @@ -1,5 +1,5 @@ -import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; -import { isDefined, isFunction, isString, isUndefined, isNumber } from '#library/assertion.library.js'; +import { toZonedDateTime, toInstant, getTemporalIds, instant } from '#library/temporal.library.js'; +import { isDefined, isFunction, isString, isUndefined, isNumber, isZonedDateTime } from '#library/assertion.library.js'; 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'; @@ -50,7 +50,9 @@ export function getTermRange(tempo: Tempo, list: Range[], keyOnly: boolean | num const chronological = sortKey([...list], 'year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond'); if (chronological.length === 0) return undefined; - const zdt = anchor ?? (tempo as any).toDateTime(); + let zdt = anchor ?? (tempo as any).toDateTime(); + if (!isZonedDateTime(zdt)) + zdt = instant().toZonedDateTimeISO((tempo as any).config?.timeZone ?? 'UTC'); // determine the largest unit defined in the range list, and use the unit above it as rollover const unit = getLargestUnit(list); @@ -69,11 +71,13 @@ export function getTermRange(tempo: Tempo, list: Range[], keyOnly: boolean | num obj[u] = (i <= 2) ? 1 : 0; } else { const fallback = (anchor as any)[u]; - obj[u] = isNumber(fallback) ? fallback : (i <= 2 ? 1 : 0); + obj[u] = (isNumber(fallback) && !Number.isNaN(fallback)) ? fallback : (i <= 2 ? 1 : 0); } } // @ts-ignore - const resZdt = toZonedDateTime({ ...obj, timeZone: anchor.timeZoneId, calendar: anchor.calendarId }); + const [timeZone, calendar] = getTemporalIds(anchor.timeZoneId, anchor.calendarId); + const resZdt = toZonedDateTime({ ...obj, timeZone, calendar }); + // @ts-ignore return new (getHost(tempo))(resZdt, (tempo as any).config); } diff --git a/packages/tempo/src/plugin/term/standard.index.ts b/packages/tempo/src/plugin/term/standard.index.ts deleted file mode 100644 index 93bf5c4..0000000 --- a/packages/tempo/src/plugin/term/standard.index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Tempo } from '../../tempo.class.js'; -import { onRegistryReset } from '../../support/tempo.register.js'; -import { TermsModule } from './term.index.js'; - -// Side-effect: Automatically register all standard terms -Tempo.extend(TermsModule); - -// Resilience: Ensure terms are restored after a registry reset -onRegistryReset(() => { - Tempo.extend(TermsModule); -}); diff --git a/packages/tempo/src/plugin/term/term.index.ts b/packages/tempo/src/plugin/term/term.index.ts index 3f3eb63..685f296 100644 --- a/packages/tempo/src/plugin/term/term.index.ts +++ b/packages/tempo/src/plugin/term/term.index.ts @@ -1,12 +1,11 @@ import { defineModule } from '../plugin.util.js' import { getRuntime, onRegistryReset } from '#tempo/support'; +import { Tempo } from '../../tempo.class.js'; import { QuarterTerm } from './term.quarter.js' import { SeasonTerm } from './term.season.js' import { ZodiacTerm } from './term.zodiac.js' import { TimelineTerm } from './term.timeline.js' -import type { Tempo } from '../../tempo.class.js'; - /** collection of built-in terms for initial registration */ export const StandardTerms = [QuarterTerm, SeasonTerm, ZodiacTerm, TimelineTerm]; export { defineTerm, defineRange, getTermRange } from '../term.util.js'; @@ -20,3 +19,11 @@ export const TermsModule = defineModule({ TempoClass.extend(StandardTerms); }, }); + +// Side-effect: Automatically register all standard terms when this barrel is imported +Tempo.extend(TermsModule); + +// Resilience: Ensure terms are restored after a registry reset +onRegistryReset(() => { + Tempo.extend(TermsModule); +}); diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index c6433cd..d8db893 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -642,15 +642,16 @@ export class Tempo { const type = config.scope === 'period' ? 'per' : (config.scope === 'event' ? 'evt' : undefined); if (type) { const aliases: [string, any][] = []; + const monthKeys = Tempo.MONTH.keys(); config.ranges.forEach(r => { if (r.key) { - const val = isDefined(r.hour) ? `${r.hour}:${pad(r.minute ?? 0)}` : (r.month ? `${pad(r.day ?? 1)} ${Tempo.MONTH.keys()[r.month - 1]}` : undefined); + const val = isDefined(r.hour) ? `${r.hour}:${pad(r.minute ?? 0)}` : (r.month ? `${pad(r.day ?? 1)} ${monthKeys[r.month - 1]}` : undefined); if (val) aliases.push([r.key, val]); } }); if (aliases.length > 0) { - const state = (this as any)[sym.$Internal](); + const state = (this as any)[$Internal](); if (type === 'per') (this as any)[$setPeriods](state, aliases); else if (type === 'evt') (this as any)[$setEvents](state, aliases); } diff --git a/packages/tempo/test/plugins/fiscal-cycle.core.test.ts b/packages/tempo/test/plugins/fiscal-cycle.core.test.ts index 203097a..c1c41ca 100644 --- a/packages/tempo/test/plugins/fiscal-cycle.core.test.ts +++ b/packages/tempo/test/plugins/fiscal-cycle.core.test.ts @@ -1,8 +1,8 @@ import { Tempo } from '#tempo/core'; import '#tempo/parse'; +import '#tempo/term'; import '#tempo/mutate'; import { FormatModule } from '#tempo/format'; -import '#tempo/term/standard'; Tempo.extend(FormatModule); diff --git a/packages/tempo/test/plugins/term-dispatch.core.test.ts b/packages/tempo/test/plugins/term-dispatch.core.test.ts index 697d035..cfb17bb 100644 --- a/packages/tempo/test/plugins/term-dispatch.core.test.ts +++ b/packages/tempo/test/plugins/term-dispatch.core.test.ts @@ -2,7 +2,7 @@ import { Tempo } from '#tempo/core'; import '#tempo/parse'; import '#tempo/mutate'; import '#tempo/format'; -import '#tempo/term/standard'; +import '#tempo/term'; describe('Term Dispatch Refactor', () => { it('should set term by index (#quarter: 2)', () => { diff --git a/packages/tempo/test/plugins/ticker.term.core.test.ts b/packages/tempo/test/plugins/ticker.term.core.test.ts index a292972..cc1f788 100644 --- a/packages/tempo/test/plugins/ticker.term.core.test.ts +++ b/packages/tempo/test/plugins/ticker.term.core.test.ts @@ -1,6 +1,6 @@ import { Tempo } from '#tempo/core'; import '#tempo/parse'; -import '#tempo/term/standard'; +import '#tempo/term'; import { MutateModule } from '#tempo/mutate'; import { TickerModule } from '#tempo/ticker'; diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 049ae87..3605aec 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -35,7 +35,6 @@ export default defineConfig({ alias: isDist ? [ { find: /^#tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') }, - { find: /^#tempo\/term\/standard$/, replacement: resolve(__dirname, './dist/plugin/term/standard.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') }, @@ -56,7 +55,6 @@ export default defineConfig({ ] : [ { find: /^#tempo\/core$/, replacement: resolve(__dirname, './src/core.index.ts') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './src/plugin/term/term.index.ts') }, - { find: /^#tempo\/term\/standard$/, replacement: resolve(__dirname, './src/plugin/term/standard.index.ts') }, { 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') }, From 1611506dcf5e06fa31e9fc2c3a09638bd4276745 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 13:08:32 +1000 Subject: [PATCH 25/29] align setEvents|setPeriods --- packages/tempo/src/support/support.index.ts | 2 +- packages/tempo/src/support/tempo.symbol.ts | 3 +- packages/tempo/src/tempo.class.ts | 109 ++++---------------- 3 files changed, 25 insertions(+), 89 deletions(-) diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 8c45315..71d06f0 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -28,7 +28,7 @@ export { export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './tempo.symbol.js'; -export { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $buildGuard, $IsBase, $Identity, $Logify, $Discover, $ImmutableSkip } from './tempo.symbol.js'; +export { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $Logify, $Discover, $ImmutableSkip } from './tempo.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; export { getRuntime, TempoRuntime } from './tempo.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './tempo.default.js'; diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/tempo.symbol.ts index dccd025..8ef15d7 100644 --- a/packages/tempo/src/support/tempo.symbol.ts +++ b/packages/tempo/src/support/tempo.symbol.ts @@ -30,6 +30,7 @@ export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termErr /** internal static discovery helper */ export const $setDiscovery: unique symbol = Symbol.for('magmacomputing/tempo/setDiscovery') as any; /** internal static event builder */ export const $setEvents: unique symbol = Symbol.for('magmacomputing/tempo/setEvents') as any; /** internal static period builder */ export const $setPeriods: unique symbol = Symbol.for('magmacomputing/tempo/setPeriods') as any; +/** internal static alias builder */ export const $setAliases: unique symbol = Symbol.for('magmacomputing/tempo/setAliases') as any; /** internal static guard builder */ export const $buildGuard: unique symbol = Symbol.for('magmacomputing/tempo/buildGuard') as any; /** internal static base class marker */ export const $IsBase: unique symbol = Symbol.for('magmacomputing/tempo/isBase') as any; @@ -37,7 +38,7 @@ export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termErr const local = { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, - $setEvents, $setPeriods, $buildGuard, $IsBase + $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase } as const; /** @internal Unified Symbol Registry (Inherits from #library via Prototype Chain) */ diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index d8db893..6fea758 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -26,7 +26,7 @@ import { resolveMonthDay } from './support/tempo.util.js'; import { DEFAULT_LAYOUT_CLASS, resolveLayoutOrder, getLayoutOrder } from './parse/parse.layout.js'; import { datePattern } from './support/tempo.default.js'; import { setProperty, proto, hasOwn, compileRegExp, setPatterns, normalizeLayoutOrder } from './support/tempo.util.js'; -import { sym, markConfig, TermError, getRuntime, init, extendState, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, $Internal, $setConfig, $logError, $logDebug, $Identity, $setEvents, $setPeriods, $buildGuard, $IsBase, $Tempo, $Register, $Logify, $errored, $dbg, $guard, $Discover, $setDiscovery } from '#tempo/support'; +import { sym, markConfig, TermError, getRuntime, init, extendState, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, $Internal, $setConfig, $logError, $logDebug, $Identity, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Tempo, $Register, $Logify, $errored, $dbg, $guard, $Discover, $setDiscovery } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) declare module '#library/type.library.js' { @@ -140,41 +140,32 @@ export class Tempo { * {dt} is a layout that combines date-related {snippets} (e.g. dd, mm -or- evt) into a pattern against which a string can be tested. * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event */ - // TODO: check all Layouts which reference "{evt}" and update them - static [$setEvents](shape: Internal.State, provided?: [string, any][]) { + static [$setAliases](shape: Internal.State, kind: 'evt' | 'per', token: any, provided?: [string, any][]) { + const field = kind === 'evt' ? 'event' : 'period'; const parent = proto(shape); - const parentEvents = parent.parse?.event ?? {}; + const parentMap = parent.parse?.[field] ?? {}; // Identify local additions or overrides - const events = provided ?? ownEntries(shape.parse.event, true).filter(([k, v]) => { - return !(k in parentEvents) || shape.parse.event[k as string] !== parentEvents[k as string]; + const entries = provided ?? ownEntries(shape.parse[field] as any, true).filter(([k, v]) => { + return !(k in parentMap) || (shape.parse[field] as any)[k as string] !== parentMap[k as string]; }); // Sync legacy registry if provided directly if (provided) { provided.forEach(([k, v]) => { - if (!hasOwn(shape.parse.event, k)) shape.parse.event[k as string] = v; + if (!hasOwn(shape.parse[field] as any, k)) (shape.parse[field] as any)[k as string] = v; }); } - // If no local events, inherit the parent's engine (via prototype) and exit - if (events.length === 0 && !hasOwn(shape, 'aliasEngine')) + // If no local aliases, inherit the parent's engine (via prototype) and exit + if (entries.length === 0 && !hasOwn(shape, 'aliasEngine')) return; // Use the correct alias engine: static for global, instance for local let engine = shape.aliasEngine; // If we have local aliases to register, we MUST have a local engine to avoid polluting the parent - if (events.length > 0 && !hasOwn(shape, 'aliasEngine')) { - engine = shape.aliasEngine = new AliasEngine({ - parent: parent.aliasEngine, - logger: Tempo.#dbg, - config: shape.config - }); - } - - // Ensure we have an engine (for the global root) - if (!engine) { + if (!hasOwn(shape, 'aliasEngine')) { engine = shape.aliasEngine = new AliasEngine({ parent: parent.aliasEngine, logger: Tempo.#dbg, @@ -182,85 +173,29 @@ export class Tempo { }); } - const groups = engine.registerAliases('evt', events); + const groups = engine!.registerAliases(kind, entries as any); if (groups) { - const protoEvt = parent.parse?.snippet?.[Token.evt]?.source; - if (groups !== protoEvt) { + const protoRegex = parent.parse?.snippet?.[token]?.source; + if (groups !== protoRegex) { if (!hasOwn(shape.parse, 'snippet')) shape.parse.snippet = { ...shape.parse.snippet }; - setProperty(shape.parse.snippet, Token.evt, new RegExp(groups)); + setProperty(shape.parse.snippet, token, new RegExp(groups)); } } else { - // If no groups, ensure we don't have a stale or empty regex that could cause issues - if (hasOwn(shape.parse.snippet, Token.evt)) { - delete shape.parse.snippet[Token.evt as any]; + if (hasOwn(shape.parse.snippet, token)) { + delete (shape.parse.snippet as any)[token]; } } } - /** - * {tm} is a layout that combines time-related snippets (hh, mi, ss, ff, mer -or- per) into a pattern against which a string can be tested. - * because it will also include a list of periods (e.g. 'midnight' | 'afternoon' ), we need to rebuild {tm} if the user adds a new period - */ - // TODO: check all Layouts which reference "{per}" and update them - static [$setPeriods](shape: Internal.State, provided?: [string, any][]) { - const parent = proto(shape); - const parentPeriods = parent.parse?.period ?? {}; - - // Identify local additions or overrides - const periods = provided ?? ownEntries(shape.parse.period, true).filter(([k, v]) => { - return !(k in parentPeriods) || shape.parse.period[k as string] !== parentPeriods[k as string]; - }); - - // Sync legacy registry if provided directly - if (provided) { - provided.forEach(([k, v]) => { - if (!hasOwn(shape.parse.period, k)) shape.parse.period[k as string] = v; - }); - } - - // If no local periods, inherit the parent's engine (via prototype) and exit - if (periods.length === 0 && !hasOwn(shape, 'aliasEngine')) - return; - - // Use the correct alias engine: static for global, instance for local - let engine = shape.aliasEngine; - - // If we have local aliases to register, we MUST have a local engine to avoid polluting the parent - if (periods.length > 0 && !hasOwn(shape, 'aliasEngine')) { - engine = shape.aliasEngine = new AliasEngine({ - parent: parent.aliasEngine, - logger: Tempo.#dbg, - config: shape.config - }); - } - - // Ensure we have an engine (for the global root) - if (!engine) { - engine = shape.aliasEngine = new AliasEngine({ - parent: parent.aliasEngine, - logger: Tempo.#dbg, - config: shape.config - }); - } - - const groups = engine.registerAliases('per', periods); - - if (groups) { - const protoPer = parent.parse?.snippet?.[Token.per]?.source; - if (groups !== protoPer) { - if (!hasOwn(shape.parse, 'snippet')) - shape.parse.snippet = { ...shape.parse.snippet }; + // TODO: check all Layouts which reference "{evt}" and update them + static [$setEvents](shape: Internal.State, provided?: [string, any][]) { + this[$setAliases](shape, 'evt', Token.evt, provided); + } - setProperty(shape.parse.snippet, Token.per, new RegExp(groups)); - } - } else { - // If no groups, ensure we don't have a stale or empty regex that could cause issues - if (hasOwn(shape.parse.snippet, Token.per)) { - delete shape.parse.snippet[Token.per as any]; - } - } + static [$setPeriods](shape: Internal.State, provided?: [string, any][]) { + this[$setAliases](shape, 'per', Token.per, provided); } /** try to infer hemisphere using the timezone's daylight-savings setting */ From c2fabf2c9404aa4880d5a991d4442572692b07ee Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 13:36:45 +1000 Subject: [PATCH 26/29] PR 1st review --- packages/tempo/.vitepress/config.ts | 2 +- packages/tempo/src/discrete/discrete.parse.ts | 11 ++++++-- packages/tempo/src/engine/engine.alias.ts | 25 ++++++++++++++++--- packages/tempo/src/engine/engine.composer.ts | 1 + packages/tempo/src/support/tempo.init.ts | 7 ++++-- packages/tempo/src/tempo.class.ts | 4 +-- 6 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index ea0fc9f..34610d0 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -13,7 +13,7 @@ export default defineConfig({ title: "Tempo", description: "The Professional Date-Time Library for Temporal", srcDir: '.', - srcExclude: ['**/plan/**', '**/archive/**', '**/bench/**', '**/scratch/**', 'CHANGELOG.md', 'CONTRIBUTING.md'], + srcExclude: ['**/plan/**', '**/archive/**', '**/bench/**', '**/scratch/**', 'CHANGELOG.md'], markdown: { math: true }, diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index 983e2da..7745ff3 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -409,8 +409,15 @@ const _ParseEngine = { if (!res) continue; try { - const type = res.type === 'evt' ? 'Event' : 'Period'; - const pat = res.type === 'evt' ? 'dt' : 'tm'; + 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 } }); diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 0ee8d2a..32bf815 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -121,7 +121,7 @@ export class AliasEngine { `[AliasEngine] Collision detected for ${type} alias "${name}". This may overwrite an existing alias.` ); - this.#words[baseWord] = name; // track the base word for collision detection + this.#words[baseWord] = aliasKey; // track the base word for collision detection this.#state[aliasKey] = { name, // plain string or regex-like string target, // string, number, or function @@ -147,10 +147,17 @@ export class AliasEngine { for (const [alias, register] of ownEntries(this.#state)) { if (register.type === type && !seenBaseNames.has(register.baseWord)) { - seenBaseNames.add(register.baseWord); + // Check for cross-type collision priority (Events win over Periods) + if (type === 'per') { + const winnerKey = this.#words[register.baseWord]; + const winner = this.getAlias(winnerKey); + if (winner && winner.type === 'evt') { + continue; // Skip period if an event is using the same baseWord + } + } - if (register.type === type) - patterns.push(`(?<${alias}>${register.name})`); + seenBaseNames.add(register.baseWord); + patterns.push(`(?<${alias}>${register.name})`); } } @@ -225,6 +232,16 @@ export class AliasEngine { delete this.#state[registry as AliasKey]; } } + + // Rebuild #words and re-calculate counts from remaining state + this.#words = Object.create(this.#parent ? (this.#parent as any).#words : null); + this.#count = { evt: 0, per: 0 }; + + for (const [key, register] of ownEntries(this.#state)) { + this.#words[register.baseWord] = key; + this.#count[register.type]++; + } + this.#version++; } diff --git a/packages/tempo/src/engine/engine.composer.ts b/packages/tempo/src/engine/engine.composer.ts index d1fcc5a..9289d01 100644 --- a/packages/tempo/src/engine/engine.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -77,6 +77,7 @@ export function compose( if (Number.isNaN(value) || !Number.isFinite(value)) { logError(config, `Invalid Tempo number: ${value}`); temporal = today; + break; } // If it's an integer and we're in 'ms' mode, treat as milliseconds diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index 3f2018b..a94a899 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -36,8 +36,11 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int event: Object.assign({}, baseState?.parse.event ?? Event), period: Object.assign({}, baseState?.parse.period ?? Period), ignore: baseState ? { ...baseState.parse.ignore } : Object.fromEntries(asArray(Ignore).map(w => [w, w])), - monthDay: baseState ? Object.create(baseState.parse.monthDay) : resolveMonthDay({}, Default.monthDay as any), - planner: baseState ? Object.create(baseState.parse.planner) : { + monthDay: baseState ? resolveMonthDay({ ...baseState.parse.monthDay }, baseState.parse.monthDay) : resolveMonthDay({}, Default.monthDay as any), + planner: baseState ? { + layoutOrder: [...asArray(baseState.parse.planner.layoutOrder)], + preFilter: Boolean(baseState.parse.planner.preFilter), + } : { layoutOrder: asArray(Default.planner?.layoutOrder ?? (Default as any).layoutOrder), preFilter: Boolean(Default.planner?.preFilter ?? (Default as any).preFilter), }, diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 6fea758..b5d2ab8 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -191,11 +191,11 @@ export class Tempo { // TODO: check all Layouts which reference "{evt}" and update them static [$setEvents](shape: Internal.State, provided?: [string, any][]) { - this[$setAliases](shape, 'evt', Token.evt, provided); + (this as any)[$setAliases](shape, 'evt', Token.evt, provided); } static [$setPeriods](shape: Internal.State, provided?: [string, any][]) { - this[$setAliases](shape, 'per', Token.per, provided); + (this as any)[$setAliases](shape, 'per', Token.per, provided); } /** try to infer hemisphere using the timezone's daylight-savings setting */ From 9d4b68d0dd90f55d2d84611cd8bccf177a2cec99 Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 15:25:29 +1000 Subject: [PATCH 27/29] PR 2nd review --- .github/workflows/ci.yml | 2 +- packages/tempo/src/engine/engine.alias.ts | 2 +- packages/tempo/src/support/tempo.default.ts | 2 ++ packages/tempo/src/support/tempo.init.ts | 27 +++++++++++++--- packages/tempo/src/tempo.class.ts | 35 ++++++++++++++++----- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edc9095..26245b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: name: Test with parse.preFilter enabled runs-on: ubuntu-latest timeout-minutes: 30 - if: github.ref == 'refs/heads/release-c-layout-order-planner' || github.event.pull_request.base.ref == 'main' + if: github.event_name == 'pull_request' && (github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'release-c-layout-order-planner') steps: - uses: actions/checkout@v4 - name: Set up Node.js diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 32bf815..a59ca70 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -157,7 +157,7 @@ export class AliasEngine { } seenBaseNames.add(register.baseWord); - patterns.push(`(?<${alias}>${register.name})`); + patterns.push(`(?<${alias}>${Match.safeAlias(register.name)})`); } } diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 2aa1261..110d546 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -36,6 +36,8 @@ export const Match = proxify({ /** anchored version for shifter resolution */ slick: /^(?#[\w]+|[\w]+)\.(?[\+\-\<\>]=?|next|prev|this|last)?(?[0-9]+)?(?[\w]*)$/, /** extracted value-only version of a slick shifter */ slickValue: /^(?[\+\-\<\>]=?|next|prev|this|last)?(?[0-9]+)?(?[\w]*)$/, /** escape special regex characters in a string */ escape: (str: string) => String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + /** escape only dangerous quantifiers and anchors to prevent backtracking/injection while allowing basic regex */ + safeAlias: (str: string) => String(str).replace(/[*+{}!^$\\]/g, '\\$&'), /** numeric-only string detection */ numeric: /^\s*[-+]?\d+(\.\d+)?\s*$/, /** match suspicious nested quantifiers (backtracking) */ backtrack: /(\(.*\)\+|\(.*\)\*|\(.*\)\{.*\})/, }, true, false); diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index a94a899..5c33ff7 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -28,7 +28,7 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int }) as t.Internal.State; // 1. Establish the base parsing state - state.parse = markConfig({ + const parseState: t.Internal.Parse = { token: Token, result: [], snippet: Object.assign({}, baseState?.parse.snippet ?? Snippet), @@ -36,10 +36,18 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int event: Object.assign({}, baseState?.parse.event ?? Event), period: Object.assign({}, baseState?.parse.period ?? Period), ignore: baseState ? { ...baseState.parse.ignore } : Object.fromEntries(asArray(Ignore).map(w => [w, w])), - monthDay: baseState ? resolveMonthDay({ ...baseState.parse.monthDay }, baseState.parse.monthDay) : resolveMonthDay({}, Default.monthDay as any), + monthDay: baseState ? { + ...baseState.parse.monthDay, + locales: [...asArray(baseState.parse.monthDay.locales)], + layouts: [...asArray(baseState.parse.monthDay.layouts)], + timezones: { ...baseState.parse.monthDay.timezones }, + ...(baseState.parse.monthDay.resolvedLocales ? { + resolvedLocales: baseState.parse.monthDay.resolvedLocales.map((l: any) => ({ ...l, timeZones: [...l.timeZones] })) + } : {}) + } : resolveMonthDay({}, Default.monthDay as any), planner: baseState ? { - layoutOrder: [...asArray(baseState.parse.planner.layoutOrder)], - preFilter: Boolean(baseState.parse.planner.preFilter), + ...(baseState.parse.planner.layoutOrder ? { layoutOrder: [...asArray(baseState.parse.planner.layoutOrder)] } : {}), + ...(isDefined(baseState.parse.planner.preFilter) ? { preFilter: Boolean(baseState.parse.planner.preFilter) } : {}), } : { layoutOrder: asArray(Default.planner?.layoutOrder ?? (Default as any).layoutOrder), preFilter: Boolean(Default.planner?.preFilter ?? (Default as any).preFilter), @@ -48,7 +56,16 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int mode: (baseState?.parse.mode ?? Default.mode) as any, lazy: false, pattern: new Map(baseState?.parse.pattern), - }); + ...(baseState ? { + ...(isDefined(baseState.parse.isAnchored) ? { isAnchored: baseState.parse.isAnchored } : {}), + ...(isDefined(baseState.parse.anchor) ? { anchor: baseState.parse.anchor } : {}), + ...(isDefined(baseState.parse.format) ? { format: baseState.parse.format } : {}), + ...(isDefined(baseState.parse.term) ? { term: baseState.parse.term } : {}), + ...(isDefined(baseState.parse.guard) ? { guard: baseState.parse.guard } : {}), + } : {}) + }; + + state.parse = markConfig(parseState); // 2. Establish the base configuration options const configDefaults = Object.fromEntries(Object.entries(Default).filter(([key]) => enums.CONFIG.has(key))); diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index b5d2ab8..81b2d28 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -152,6 +152,7 @@ export class Tempo { // Sync legacy registry if provided directly if (provided) { + if (!hasOwn(shape.parse, field)) (shape.parse as any)[field] = { ...(shape.parse as any)[field] }; provided.forEach(([k, v]) => { if (!hasOwn(shape.parse[field] as any, k)) (shape.parse[field] as any)[k as string] = v; }); @@ -184,18 +185,22 @@ export class Tempo { } } else { if (hasOwn(shape.parse.snippet, token)) { + if (!hasOwn(shape.parse, 'snippet')) + shape.parse.snippet = { ...shape.parse.snippet }; + delete (shape.parse.snippet as any)[token]; } } } - // TODO: check all Layouts which reference "{evt}" and update them - static [$setEvents](shape: Internal.State, provided?: [string, any][]) { + static [$setEvents](shape: Internal.State, provided?: [string, any][], rebuild = true) { (this as any)[$setAliases](shape, 'evt', Token.evt, provided); + if (rebuild) setPatterns(shape); } - static [$setPeriods](shape: Internal.State, provided?: [string, any][]) { + static [$setPeriods](shape: Internal.State, provided?: [string, any][], rebuild = true) { (this as any)[$setAliases](shape, 'per', Token.per, provided); + if (rebuild) setPatterns(shape); } /** try to infer hemisphere using the timezone's daylight-savings setting */ @@ -312,8 +317,8 @@ export class Tempo { } Tempo.#swapLayout(shape); - if (isDefined(shape.parse.event)) (this as any)[$setEvents](shape); - if (isDefined(shape.parse.period)) (this as any)[$setPeriods](shape); + if (isDefined(shape.parse.event)) (this as any)[$setEvents](shape, undefined, false); + if (isDefined(shape.parse.period)) (this as any)[$setPeriods](shape, undefined, false); setPatterns(shape); } @@ -1458,12 +1463,26 @@ export class Tempo { Object.assign(this.#local.config, { scope: 'local' }); this.#local.parse = markConfig(Object.create(classState.parse)); - this.#local.parse.planner = Object.create(classState.parse.planner); // shadow the planner object - this.#local.parse.monthDay = Object.create(classState.parse.monthDay); // shadow the monthDay object + this.#local.parse.event = { ...classState.parse.event }; + this.#local.parse.period = { ...classState.parse.period }; + this.#local.parse.snippet = { ...classState.parse.snippet }; + this.#local.parse.planner = { + ...(classState.parse.planner.layoutOrder ? { layoutOrder: [...asArray(classState.parse.planner.layoutOrder)] } : {}), + ...(isDefined(classState.parse.planner.preFilter) ? { preFilter: Boolean(classState.parse.planner.preFilter) } : {}), + }; + this.#local.parse.monthDay = { + ...classState.parse.monthDay, + locales: [...asArray(classState.parse.monthDay.locales)], + layouts: [...asArray(classState.parse.monthDay.layouts)], + timezones: { ...classState.parse.monthDay.timezones }, + ...(classState.parse.monthDay.resolvedLocales ? { + resolvedLocales: classState.parse.monthDay.resolvedLocales.map((l: any) => ({ ...l, timeZones: [...l.timeZones] })) + } : {}) + }; setProperty(this.#local.parse, 'result', [...(options.result ?? [])]); Object.defineProperty(this.#local, 'tempoInstance', { // Link this instance to its state for static alias access - value: this, + value: self, writable: false, configurable: true, enumerable: false From 66e81efab98d15baae158a43f4c9d827703b1fbf Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 16:43:42 +1000 Subject: [PATCH 28/29] PR 3rd review --- .github/workflows/ci.yml | 2 +- packages/tempo/src/engine/engine.alias.ts | 33 ++++++++++++++------- packages/tempo/src/parse/parse.layout.ts | 13 ++++++-- packages/tempo/src/support/tempo.default.ts | 11 +++---- packages/tempo/src/support/tempo.init.ts | 4 +-- packages/tempo/src/tempo.class.ts | 2 +- packages/tempo/src/tempo.type.ts | 2 +- 7 files changed, 43 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26245b6..d6e24b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: name: Test with parse.preFilter enabled runs-on: ubuntu-latest timeout-minutes: 30 - if: github.event_name == 'pull_request' && (github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'release-c-layout-order-planner') + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release-c-layout-order-planner') steps: - uses: actions/checkout@v4 - name: Set up Node.js diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index a59ca70..8f803d6 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -69,7 +69,7 @@ export class AliasEngine { #depth: number; // the depth of this engine in the proto chain #count: Record; // count of aliases registered at this level (used for indexing) #state: State; // object that holds alias mappings, collisions, and registry for this engine - #words: Record; // object of base words for collision detection + #words: Record; // object of base words for collision detection #id: number; #version = 0; @@ -78,6 +78,7 @@ export class AliasEngine { } get id() { return this.#id } get parent() { return this.#parent } + getWords() { return this.#words } constructor(options = {} as AliasEngineOptions) { this.#parent = options.parent ?? null; @@ -86,8 +87,11 @@ export class AliasEngine { this.#id = AliasEngine.#idCounter++; if (this.#parent) { - if (!(this.#parent instanceof AliasEngine)) - this.#logger?.error(this.#config, "Parent engine must be an instance of AliasEngine"); + if (!(this.#parent instanceof AliasEngine)) { + const msg = "Parent engine must be an instance of AliasEngine"; + this.#logger?.error(this.#config, msg); + throw new TypeError(msg); + } 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 @@ -114,20 +118,26 @@ export class AliasEngine { const aliasKey = `${type}${this.#depth}_${index}` as AliasKey; const baseWord = AliasEngine.#getBaseWord(name); - const collision = baseWord in this.#words; // check for collision with existing base words in this engine and parent engines + const existingKey = this.#words[baseWord]; + const existing = existingKey ? this.getAlias(existingKey) : undefined; + const shouldOverwrite = !(existing?.type === 'evt' && type === 'per'); - if (collision && this.#logger) + if (this.#logger && baseWord in this.#words) { this.#logger.warn(this.#config, - `[AliasEngine] Collision detected for ${type} alias "${name}". This may overwrite an existing alias.` + `[AliasEngine] Collision detected for ${type} alias "${name}". ${shouldOverwrite ? 'Overwriting' : 'Preserving'} existing alias.` ); + } + + if (shouldOverwrite) { + this.#words[baseWord] = aliasKey; + } - this.#words[baseWord] = aliasKey; // track the base word for collision detection this.#state[aliasKey] = { name, // plain string or regex-like string target, // string, number, or function type, // 'evt' or 'per' baseWord, // used for collision detection - collision, // needed ? + collision: baseWord in this.#words, depth: this.#depth, } } @@ -142,14 +152,15 @@ export class AliasEngine { * it won't be included in the regex patterns of the parent engine, * preventing unintended matches and preserving the expected behavior of alias resolution. */ - getPatterns(type: AliasType, seenBaseNames = new Set()): string | undefined { + getPatterns(type: AliasType, seenBaseNames = new Set(), winnerLookup?: Record): string | undefined { const patterns: string[] = []; + const winners = winnerLookup ?? this.#words; for (const [alias, register] of ownEntries(this.#state)) { if (register.type === type && !seenBaseNames.has(register.baseWord)) { // Check for cross-type collision priority (Events win over Periods) if (type === 'per') { - const winnerKey = this.#words[register.baseWord]; + const winnerKey = winners[register.baseWord]; const winner = this.getAlias(winnerKey); if (winner && winner.type === 'evt') { continue; // Skip period if an event is using the same baseWord @@ -162,7 +173,7 @@ export class AliasEngine { } if (this.#parent) { - const parentPatterns = this.#parent.getPatterns(type, seenBaseNames); + const parentPatterns = this.#parent.getPatterns(type, seenBaseNames, winners); if (parentPatterns) patterns.push(parentPatterns); } diff --git a/packages/tempo/src/parse/parse.layout.ts b/packages/tempo/src/parse/parse.layout.ts index aae3df2..ae4da41 100644 --- a/packages/tempo/src/parse/parse.layout.ts +++ b/packages/tempo/src/parse/parse.layout.ts @@ -3,7 +3,7 @@ import { Token } from '#tempo/support/tempo.symbol.js'; import type * as t from '../tempo.type.js'; export type LayoutEntry = [symbol, string]; -export type LayoutController = Record; +export type LayoutController = Record; const TOKEN_ALIAS = new Map( (ownEntries(Token, true) as [string, symbol][]).map(([name, key]) => [key, name]) @@ -47,8 +47,15 @@ export function resolveLayoutClassificationOrder(layout: Record, const seen = new Set(); preferred.forEach(name => { - const resolvedName = TOKEN_DESCRIPTION_BY_NAME.get(name) ?? name; - const entry = byName.get(resolvedName) ?? byName.get(name); + const isSym = typeof name === 'symbol'; + const description = isSym ? (name.description ?? '') : ''; + const alias = isSym ? TOKEN_ALIAS.get(name) : undefined; + + const resolvedName = !isSym ? (TOKEN_DESCRIPTION_BY_NAME.get(name) ?? name) : undefined; + const entry = isSym + ? (byName.get(description) ?? (alias ? byName.get(alias) : undefined)) + : (byName.get(resolvedName!) ?? byName.get(name)); + if (!entry) return; if (seen.has(entry[0])) return; seen.add(entry[0]); diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 110d546..e76aa74 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -203,10 +203,11 @@ export const Default = secure({ /** regional date-parsing configuration */ monthDay: MONTH_DAY, /** internationalization configuration */ intl: IntlDefault, /** parse planner configuration (layoutOrder, etc.) */ planner: { - // layoutOrder: [ - // Token.hms, Token.dmy6, Token.mdy6, Token.ymd6, Token.wkd, - // Token.dt, Token.tm, Token.dtm, Token.tmd, Token.dmy, Token.mdy, Token.ymd, - // Token.off, Token.rel - // ], preFilter: false + layoutOrder: [ + Token.hms, Token.dmy6, Token.mdy6, Token.ymd6, Token.wkd, + Token.dt, Token.tm, Token.dtm, Token.tmd, Token.dmy, Token.mdy, Token.ymd, + Token.off, Token.rel + ], + preFilter: false }, } as Options) diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index 5c33ff7..9241a92 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -46,10 +46,10 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int } : {}) } : resolveMonthDay({}, Default.monthDay as any), planner: baseState ? { - ...(baseState.parse.planner.layoutOrder ? { layoutOrder: [...asArray(baseState.parse.planner.layoutOrder)] } : {}), + ...(baseState.parse.planner.layoutOrder ? { layoutOrder: [...asArray(baseState.parse.planner.layoutOrder)] } : {}), ...(isDefined(baseState.parse.planner.preFilter) ? { preFilter: Boolean(baseState.parse.planner.preFilter) } : {}), } : { - layoutOrder: asArray(Default.planner?.layoutOrder ?? (Default as any).layoutOrder), + layoutOrder: [...asArray(Default.planner?.layoutOrder ?? (Default as any).layoutOrder)], preFilter: Boolean(Default.planner?.preFilter ?? (Default as any).preFilter), }, pivot: (baseState?.parse.pivot ?? Default.pivot) as any, diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 81b2d28..471b614 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1467,7 +1467,7 @@ export class Tempo { this.#local.parse.period = { ...classState.parse.period }; this.#local.parse.snippet = { ...classState.parse.snippet }; this.#local.parse.planner = { - ...(classState.parse.planner.layoutOrder ? { layoutOrder: [...asArray(classState.parse.planner.layoutOrder)] } : {}), + ...(classState.parse.planner.layoutOrder ? { layoutOrder: [...asArray(classState.parse.planner.layoutOrder)] } : {}), ...(isDefined(classState.parse.planner.preFilter) ? { preFilter: Boolean(classState.parse.planner.preFilter) } : {}), }; this.#local.parse.monthDay = { diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index e40460c..441552f 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -153,7 +153,7 @@ export interface IntlOptions { } export interface PlannerOptions { - /** preferred parse-order of layouts */ layoutOrder?: string[]; + /** preferred parse-order of layouts */ layoutOrder?: (string | symbol)[]; /** enable parse planner pre-filtering */ preFilter?: boolean; } From dd34f024c58390a8dd79e3923e57e93652b675ff Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 6 May 2026 17:06:14 +1000 Subject: [PATCH 29/29] PR 4th review --- .github/workflows/ci.yml | 2 +- packages/tempo/src/engine/engine.alias.ts | 12 +++++++----- packages/tempo/src/tempo.class.ts | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6e24b3..30d121b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: name: Test with parse.preFilter enabled runs-on: ubuntu-latest timeout-minutes: 30 - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release-c-layout-order-planner') + if: (github.event_name == 'push' || github.event_name == 'pull_request') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release/D' || github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'release/D') steps: - uses: actions/checkout@v4 - name: Set up Node.js diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index 8f803d6..dab7848 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -28,7 +28,7 @@ type State = Record export interface AliasResult { value: string; - key: string; // The original baseName (e.g. 'noon') + key: string; // The normalized baseWord (e.g. 'noon') type: AliasType; source: 'global' | 'local'; isClock: boolean; @@ -137,7 +137,7 @@ export class AliasEngine { target, // string, number, or function type, // 'evt' or 'per' baseWord, // used for collision detection - collision: baseWord in this.#words, + collision: Boolean(existingKey), depth: this.#depth, } } @@ -185,10 +185,12 @@ export class AliasEngine { } hasAlias(name: string, type?: AliasType) { - return !(name in this.#state) + const baseWord = AliasEngine.#getBaseWord(name); + const key = this.#words[baseWord]; + return !key ? false : type - ? this.#state[name as AliasKey].type === type + ? this.#state[key].type === type : true } @@ -208,7 +210,7 @@ export class AliasEngine { return { value, - key: register.name, + key: register.baseWord, type: register.type, source: register.depth === 0 ? 'global' : 'local', isClock: Match.clock.test(value), diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 471b614..18705c6 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -585,7 +585,17 @@ export class Tempo { const monthKeys = Tempo.MONTH.keys(); config.ranges.forEach(r => { if (r.key) { - const val = isDefined(r.hour) ? `${r.hour}:${pad(r.minute ?? 0)}` : (r.month ? `${pad(r.day ?? 1)} ${monthKeys[r.month - 1]}` : undefined); + let val: string | undefined; + if (isDefined(r.hour)) { + if (Number.isInteger(r.hour) && r.hour >= 0 && r.hour <= 23) { + val = `${r.hour}:${pad(r.minute ?? 0)}`; + } + } else if (r.month) { + if (Number.isInteger(r.month) && r.month >= 1 && r.month <= 12) { + val = `${pad(r.day ?? 1)} ${monthKeys[r.month - 1]}`; + } + } + if (val) aliases.push([r.key, val]); } }); @@ -750,7 +760,7 @@ export class Tempo { const parse = state.parse; parse.pattern ??= new Map(); parse.monthDay = resolveMonthDay(Default.monthDay, Tempo.MONTH_DAY); - parse.planner.layoutOrder = asArray((Default.planner?.layoutOrder ?? (Default as any).layoutOrder) as t.Options['parseOrder']) as string[]; + parse.planner.layoutOrder = asArray(Default.planner?.layoutOrder ?? (Default as any).layoutOrder); parse.planner.preFilter = Boolean(Default.planner?.preFilter ?? (Default as any).preFilter); parse.pivot ??= Default.pivot as any; parse.mode ??= Default.mode as any;