From 5a4fdfc37f68a304c9103c8ab4cb6a025728f6bc Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 8 Jul 2026 10:02:17 +1000 Subject: [PATCH 1/2] new Tempo.versions property --- bin/version-sync.mjs | 17 ++++-- package-lock.json | 8 +-- package.json | 2 +- packages/library/package.json | 2 +- packages/tempo/CHANGELOG.md | 9 +++ packages/tempo/doc/releases/v3.x.md | 10 +++ packages/tempo/package.json | 2 +- packages/tempo/src/module/module.format.ts | 5 +- packages/tempo/src/plugin/plugin.type.ts | 1 + packages/tempo/src/plugin/plugin.util.ts | 6 +- packages/tempo/src/plugin/term/term.type.ts | 1 + packages/tempo/src/plugin/term/term.util.ts | 2 + packages/tempo/src/tempo.class.ts | 68 +++++++++++---------- 13 files changed, 84 insertions(+), 49 deletions(-) diff --git a/bin/version-sync.mjs b/bin/version-sync.mjs index f6455cf2..a0440b32 100644 --- a/bin/version-sync.mjs +++ b/bin/version-sync.mjs @@ -10,12 +10,17 @@ if (!version) { console.log(`\n🔄 Syncing version ${version} to workspaces...`); try { - execSync( - `npm version ${version} -w @magmacomputing/tempo -w @magmacomputing/library --no-git-tag-version`, - { stdio: 'inherit' } - ); - console.log(`✅ Version successfully synced to ${version} across workspaces!\n`); + const workspaces = ['@magmacomputing/tempo', '@magmacomputing/library']; + for (const ws of workspaces) { + try { + execSync(`npm version ${version} -w ${ws} --no-git-tag-version`, { stdio: 'inherit' }); + console.log(`✅ Synced ${ws} to ${version}`); + } catch (error) { + console.warn(`⚠️ Bypassed ${ws} (likely already at ${version} or not found).`); + } + } + console.log(`\n🎉 Version sync complete!\n`); } catch (error) { - console.error('Failed to sync versions.', error); + console.error('Fatal error during sync.', error); process.exit(1); } diff --git a/package-lock.json b/package-lock.json index 72e0f722..519de593 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tempo-monorepo", - "version": "3.6.1", + "version": "3.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tempo-monorepo", - "version": "3.6.1", + "version": "3.7.0", "workspaces": [ "packages/*" ], @@ -10149,7 +10149,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.6.1", + "version": "3.7.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -10160,7 +10160,7 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.6.1", + "version": "3.7.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index af674e01..b390d917 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.6.1", + "version": "3.7.0", "private": true, "engines": { "node": ">=20.0.0" diff --git a/packages/library/package.json b/packages/library/package.json index a73c8f3e..ed7e6776 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.6.1", + "version": "3.7.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index d2f39537..e532dd91 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -6,6 +6,15 @@ 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). +## [3.7.0] - 2026-07-08 + +### Added +- **Runtime Versioning Registry**: Introduced a secure, static `Tempo.versions` registry. This provides zero-burden runtime observability of all loaded core modules and community plugins. +- **Automated Plugin Versioning**: Community plugins now automatically inject their version via a custom ESBuild virtual module pipeline, eliminating the need for magic strings. Internal bundled terms (like `QuarterTerm`) seamlessly inherit the core `TEMPO_VERSION`. + +### Changed +- **Internal Privacy Modernization**: Refactored internal runtime registries (including `_termMap` and other internal configuration variables) to utilize strict ECMAScript private fields (`#`), ensuring complete architectural security against prototype tampering. + ## [3.6.1] - 2026-07-06 ### Fixed diff --git a/packages/tempo/doc/releases/v3.x.md b/packages/tempo/doc/releases/v3.x.md index 7f9c4448..0ed854e8 100644 --- a/packages/tempo/doc/releases/v3.x.md +++ b/packages/tempo/doc/releases/v3.x.md @@ -1,5 +1,15 @@ # 📜 Version 3.x History +## [v3.7.0] - 2026-07-08 + +### ✨ What's New — Runtime Version Registry +Tempo now features a fully automated runtime version registry accessible via `Tempo.versions`. This allows developers to instantly query exactly which core modules, terms, and community plugins are loaded into their current Tempo environment and what versions they are running. This system is completely zero-burden for plugin authors, utilizing a virtual build pipeline to auto-inject version strings without requiring manual updates or magic strings. + +### 🏗️ Internal Refactoring +- **Architectural Security**: The internal state management (including term registries and configuration variables) has been modernized to use true ECMAScript private fields (`#`), ensuring that Tempo's runtime state is strictly impenetrable from the outside. + +--- + ## [v3.6.0] - 2026-07-05 ### ✨ What's New — Shorthand Mutation Keys diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 3a743fc7..5b3e6095 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.6.1", + "version": "3.7.0", "engines": { "node": ">=20.0.0" }, diff --git a/packages/tempo/src/module/module.format.ts b/packages/tempo/src/module/module.format.ts index 748c6a9f..2bf0119b 100644 --- a/packages/tempo/src/module/module.format.ts +++ b/packages/tempo/src/module/module.format.ts @@ -7,8 +7,9 @@ import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDat import { formatDayPeriod, getDTF, getPR, getISOWeekOfYear } from '#library/international.library.js'; import { delegator } from '#library/proxy.library.js'; -import { isTempo, enums, Match, getRuntime, NumericPattern, BigIntPattern, hasOwn } from '#tempo/support'; +import { isTempo, enums, Match, getRuntime, NumericPattern, BigIntPattern, hasOwn, $Internal } from '#tempo/support'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; +import { findTermPlugin } from '../plugin/term/term.util.js'; import type { Tempo } from '../tempo.class.js'; @@ -277,7 +278,7 @@ export function format(obj?: any, fmt?: any, options?: any): any { if (token.startsWith('#') && isTempo(obj)) { const termKey = token.slice(1); const termName = termKey.split('.')[0]; - const plugin = (obj.constructor as any)._termMap?.get(termName); + const plugin = findTermPlugin(termName, (obj.constructor as any)[$Internal]()); if (plugin) { const termVal = (obj as unknown as Tempo).term[termKey]; diff --git a/packages/tempo/src/plugin/plugin.type.ts b/packages/tempo/src/plugin/plugin.type.ts index 58ad99ec..07eb3ce5 100644 --- a/packages/tempo/src/plugin/plugin.type.ts +++ b/packages/tempo/src/plugin/plugin.type.ts @@ -4,6 +4,7 @@ */ export interface Plugin { name: string; + version?: string; install: (this: T, t: T) => void; } diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 64adf52d..734f331c 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -6,13 +6,12 @@ import { TempoError } from '../support/support.error.js'; import { getRuntime } from '../support/support.runtime.js'; import { hasOwn, logError } from '#tempo/support/support.util.js'; import type { Tempo } from '../tempo.class.js'; -import type { Plugin, Module, Extension } from './plugin.type.js'; +import type { Plugin, Module } from './plugin.type.js'; +import { TEMPO_VERSION } from '../tempo.version.js'; export type TempoType = typeof Tempo; export type TempoPlugin = Plugin; export type TempoModule = Module; -/** @deprecated Use `TempoPlugin` instead. */ -export type TempoExtension = Extension; export function getHost(t: any): any { const TempoClass = getRuntime().modules['Tempo']; @@ -138,6 +137,7 @@ export function attachStatics(TempoClass: any, props: Record) { export function defineInterpreterModule(name: string, logic: any, statics?: Record) { return defineModule({ name, + version: TEMPO_VERSION, install(this: TempoType, TempoClass: TempoType) { const rt = getRuntime(); const modules = rt.modules; diff --git a/packages/tempo/src/plugin/term/term.type.ts b/packages/tempo/src/plugin/term/term.type.ts index 2f552493..76f6a60d 100644 --- a/packages/tempo/src/plugin/term/term.type.ts +++ b/packages/tempo/src/plugin/term/term.type.ts @@ -17,6 +17,7 @@ export type TempoTermType = typeof Tempo & { */ export interface TermPlugin { key: string; + version?: string; scope?: string; description?: string; locale?: Record; diff --git a/packages/tempo/src/plugin/term/term.util.ts b/packages/tempo/src/plugin/term/term.util.ts index 5ccb40c4..e71a23ce 100644 --- a/packages/tempo/src/plugin/term/term.util.ts +++ b/packages/tempo/src/plugin/term/term.util.ts @@ -11,12 +11,14 @@ import { getRuntime } from '../../support/support.runtime.js'; import { SCHEMA, getLargestUnit } from '../../support/support.util.js'; import type { Tempo } from '../../tempo.class.js'; import type { TermPlugin, Range, ResolvedRange } from './term.type.js'; +import { TEMPO_VERSION } from '../../tempo.version.js'; /** * ## defineTerm * Helper to register a Term plugin. */ export const defineTerm = (term: T): T => { + if (!term.version) term.version = TEMPO_VERSION; registerTerm(term); return deepFreeze(term) as T; } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index c39a2fff..6f4f9254 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -101,12 +101,16 @@ export class Tempo { /** @internal check if Tempo is currently initializing */ static get isInitializing() { return !_lifecycle.ready } /** @internal check if Tempo is currently extending */ static get isExtending() { return _lifecycle.extendDepth > 0 } + + static #versions: Record = { Tempo: TEMPO_VERSION }; + /** the active versions of Tempo and any registered plugins or modules */ + static get versions() { return Object.freeze({ ...Tempo.#versions }) as Readonly>; } /** the version of this Tempo build (stamped at build-time from package.json) */ - static get version() { return TEMPO_VERSION } + static get version() { return Tempo.#versions['Tempo']; } - /** mutable list of registered term plugins */ private static get _terms(): TermPlugin[] { return this[$Internal]().pluginsDb.terms } + /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return this[$Internal]().pluginsDb.terms } /** @internal raw license state — sandbox-aware: reads sandbox-local license if present, otherwise global */ - private static get _license() { return getLicenseState(this[$Internal]()); } + static get #license() { return getLicenseState(this[$Internal]()); } /** human-readable formatted license state */ static get license() { const { jws, key, ...raw } = getLicenseSnapshot(this[$Internal]()); // omit internal Pledge and JWT string from user-facing snapshot const ss = { timeStamp: 'ss' } as const; // JWT timestamps are always in seconds (RFC 7519) @@ -129,7 +133,7 @@ export class Tempo { ...(isNumber(raw.issuedAt) && { issuedAt: new Tempo(raw.issuedAt, ss).fmt.weekTime }), }); } - /** mapping of terms to their resolved values */ private static _termMap: Map = new Map(); + /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); /** Master Guard predicate (implements RegExp-like interface) */static get [$guard]() { return (this[$Internal]() as any)[$guard] ?? { test: () => true }; } @@ -223,7 +227,7 @@ export class Tempo { } /** try to infer hemisphere using the timezone's daylight-savings setting */ - private static _setSphere = (shape: Internal.State, options: t.Options) => { + static #setSphere = (shape: Internal.State, options: t.Options) => { if (isDefined(options.sphere)) return options.sphere; const tz = options.timeZone; @@ -238,14 +242,14 @@ export class Tempo { } /** determine if we have a {timeZone} which prefers {mdy} date-order */ - private static _isMonthDay(shape: Internal.State) { + static #isMonthDay(shape: Internal.State) { const { timeZone, locale } = shape.config; const mdy = shape.parse.monthDay; const globalMdy = Tempo.MONTH_DAY as t.MonthDay; let intl: Intl.Locale; try { - intl = new Intl.Locale(Tempo._locale(locale)); + intl = new Intl.Locale(Tempo.#locale(locale)); } catch (e) { logWarn(`Invalid locale encountered in #isMonthDay: ${locale}. Falling back to en-US.`, shape.config, e); intl = new Intl.Locale('en-US'); @@ -264,11 +268,11 @@ export class Tempo { * swap parsing-order of layouts to suit different timeZones * this allows the parser to try to interpret '04012023' as Apr-01-2023 before trying 04-Jan-2023 */ - private static _swapLayout(shape: Internal.State) { + static #swapLayout(shape: Internal.State) { const { layouts } = shape.parse.monthDay; if (isEmpty(layouts)) return; - const isMonthDay = shape.parse.monthDay.isExplicit ? shape.parse.monthDay.active! : Tempo._isMonthDay(shape); + 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 @@ -294,7 +298,7 @@ export class Tempo { } /** get first Canonical name of a supplied locale */ - private static _locale = (locale?: string | string[]) => { + static #locale = (locale?: string | string[]) => { const global = Context.global; let language: string | undefined; @@ -331,7 +335,7 @@ export class Tempo { } // Side-effects - const newSphere = Tempo._setSphere(shape, mergedOptions); + const newSphere = Tempo.#setSphere(shape, mergedOptions); if (shape.config.scope === 'local') { const parentSphere = Object.getPrototypeOf(shape.config).sphere; if (newSphere !== parentSphere) shape.config.sphere = newSphere; @@ -340,7 +344,7 @@ export class Tempo { } const oldLayout = shape.parse.layout; - Tempo._swapLayout(shape); + Tempo.#swapLayout(shape); if (oldLayout !== shape.parse.layout) needsRebuild = true; if (isDefined(shape.parse.event)) { @@ -466,8 +470,8 @@ export class Tempo { ...ownKeys(state.parse.snippet), ...ownKeys(state.parse.layout), ...[Token.slk], - ...Tempo._terms.map(t => t.key), - ...Tempo._terms.map(t => t.scope), + ...Tempo.#terms.map(t => t.key), + ...Tempo.#terms.map(t => t.scope), ...Guard, ...(state.config.registry?.modifiers ? Object.values(state.config.registry.modifiers).flat() : []) ]; @@ -485,7 +489,7 @@ export class Tempo { } /** @internal resolve a global discovery config object by symbol key */ - private static _getConfig(sym: symbol) { + static #getConfig(sym: symbol) { const discovery = (globalThis as Record)[sym]; return proxify(omit({ ...discovery, scope: 'discovery' }, 'value')); } @@ -565,6 +569,7 @@ export class Tempo { installed.add(name); registerPlugin(item, state); + if ((item as any).version) Tempo.#versions[name] = (item as any).version; (item as TempoPlugin).install.call(this as any, this); } else if (isObject(item)) { @@ -573,9 +578,9 @@ export class Tempo { const config = item as TermPlugin; const state = this[$Internal](); - if (Tempo._termMap.get(config.key) === config) return; - if (Tempo._termMap.has(config.key)) { - const existing = Tempo._termMap.get(config.key); + if (Tempo.#termMap.get(config.key) === config) return; + if (Tempo.#termMap.has(config.key)) { + const existing = Tempo.#termMap.get(config.key); const rangesMatch = JSON.stringify(existing?.ranges) === JSON.stringify(config.ranges); if (existing?.scope === config.scope && existing?.description === config.description && rangesMatch) { logDebug(`[Tempo#extend] Duplicate term registration ignored for key: "${config.key}"`, state.config); @@ -584,9 +589,9 @@ export class Tempo { logError(`[Tempo#extend] Term collision on key: "${config.key}". Registration aborted.`, state.config); return; } - if (config.scope && Tempo._termMap.get(config.scope) === config) { /* continue */ } - else if (config.scope && Tempo._termMap.has(config.scope)) { - const existingScope = Tempo._termMap.get(config.scope); + if (config.scope && Tempo.#termMap.get(config.scope) === config) { /* continue */ } + else if (config.scope && Tempo.#termMap.has(config.scope)) { + const existingScope = Tempo.#termMap.get(config.scope); const rangesMatch = JSON.stringify(existingScope?.ranges) === JSON.stringify(config.ranges); if (existingScope?.key === config.key && existingScope?.description === config.description && rangesMatch) { /* continue */ @@ -596,8 +601,9 @@ export class Tempo { } } - Tempo._termMap.set(config.key, config); - if (config.scope) Tempo._termMap.set(config.scope, config); + Tempo.#termMap.set(config.key, config); + if (config.scope) Tempo.#termMap.set(config.scope, config); + if (config.version) Tempo.#versions[`${config.key}Term`] = config.version; registerTerm(config, this[$Internal]()); @@ -789,7 +795,7 @@ export class Tempo { // Resolve locale if missing or invalid const currentLocale = config.locale; - const locale = (!currentLocale || currentLocale === 'en-US') ? Tempo._locale(currentLocale) : currentLocale; + const locale = (!currentLocale || currentLocale === 'en-US') ? Tempo.#locale(currentLocale) : currentLocale; if (!hasOwn(config, 'get')) { Object.defineProperty(config, 'get', { @@ -798,12 +804,12 @@ export class Tempo { }); } - _usrCount = 0; // reset user-key counter + _usrCount = 0; // reset user-key counter for (const key of Object.keys(Token)) // purge user-allocated Tokens if (key.startsWith('usr.')) // only remove 'usr.' prefixed keys delete Token[key]; - Tempo._termMap.clear(); // clear term lookup map + Tempo.#termMap.clear(); // clear term lookup map registryReset(); // purge formats and numbers // 3. Apply configuration via unified setters (non-destructive merge) @@ -949,7 +955,7 @@ export class Tempo { static get discovery() { const discovery = this.config.discovery; const sym = isString(discovery) ? Symbol.for(discovery) : discovery; - return Tempo._getConfig(sym as symbol); + return Tempo.#getConfig(sym as symbol); } static get options() { @@ -996,7 +1002,7 @@ export class Tempo { /** static Tempo.terms (registry) */ static get terms(): Secure & Record { const rt = getRuntime(); - const list = Tempo._terms.map(({ define, resolve, ...rest }) => { + const list = Tempo.#terms.map(({ define, resolve, ...rest }) => { const item = { ...rest } as any; if (hasOwn(rt.license.scopes, rest.key)) { const meta = rt.license.scopes[rest.key]; @@ -1144,7 +1150,7 @@ export class Tempo { static [$errored] = $errored; /** @internal */ static [TermError](config: Internal.Config, term: string): void { - const hint = Tempo._terms.length === 0 ? ". (No term plugins are registered—did you forget to call Tempo.extend(TermsModule)?)" : ""; + const hint = Tempo.#terms.length === 0 ? ". (No term plugins are registered—did you forget to call Tempo.extend(TermsModule)?)" : ""; const msg = `Unknown Term identifier: ${term}${hint}`; logError(msg, config); } @@ -1379,7 +1385,7 @@ export class Tempo { // 🛡️ Lazy Proxy Guard (Licensing) if (this.#isBlocked(key)) return undefined; - const term = Tempo._termMap.get(key); + const term = Tempo.#termMap.get(key); if (term) { const isKeyOnly = term.key === key; const define = (keyOnly: boolean) => { @@ -1422,7 +1428,7 @@ export class Tempo { if (isString(key)) this.#setLazy(target, key, () => this.format(key as t.Format)); }); } else { - Tempo._terms.forEach(term => { + Tempo.#terms.forEach(term => { const define = (keyOnly: boolean, anchor?: any) => { // 🛡️ Resolution Guard (Licensing) if (getRuntime().license.status !== LICENSE.Active && this.#isBlocked(term.key)) return undefined; From b65c12f030aca3e90bbfb7f201d8118005b5656c Mon Sep 17 00:00:00 2001 From: Michael McRae Date: Wed, 8 Jul 2026 11:10:52 +1000 Subject: [PATCH 2/2] PR 1st review --- bin/version-sync.mjs | 10 +++++++++- packages/tempo/src/tempo.class.ts | 11 +++++++++-- packages/tempo/src/tempo.version.ts | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/bin/version-sync.mjs b/bin/version-sync.mjs index a0440b32..01e354d4 100644 --- a/bin/version-sync.mjs +++ b/bin/version-sync.mjs @@ -11,14 +11,22 @@ console.log(`\n🔄 Syncing version ${version} to workspaces...`); try { const workspaces = ['@magmacomputing/tempo', '@magmacomputing/library']; + let syncedCount = 0; for (const ws of workspaces) { try { execSync(`npm version ${version} -w ${ws} --no-git-tag-version`, { stdio: 'inherit' }); console.log(`✅ Synced ${ws} to ${version}`); + syncedCount++; } catch (error) { - console.warn(`⚠️ Bypassed ${ws} (likely already at ${version} or not found).`); + console.warn(`⚠️ Bypassed ${ws} (likely already at ${version} or not found). Error details:`, error); } } + + if (syncedCount === 0) { + console.error(`\n✖ Sync failed: All workspaces were bypassed (already at ${version} or not found).`); + process.exit(1); + } + console.log(`\n🎉 Version sync complete!\n`); } catch (error) { console.error('Fatal error during sync.', error); diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 6f4f9254..5952badd 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -569,7 +569,10 @@ export class Tempo { installed.add(name); registerPlugin(item, state); - if ((item as any).version) Tempo.#versions[name] = (item as any).version; + if ((item as any).version) { + const suffix = name.endsWith('Plugin') || name.endsWith('Module') ? '' : 'Plugin'; + Tempo.#versions[`${name}${suffix}`] = (item as any).version; + } (item as TempoPlugin).install.call(this as any, this); } else if (isObject(item)) { @@ -603,7 +606,11 @@ export class Tempo { Tempo.#termMap.set(config.key, config); if (config.scope) Tempo.#termMap.set(config.scope, config); - if (config.version) Tempo.#versions[`${config.key}Term`] = config.version; + + if (config.version) { + const name = config.scope || config.key; + Tempo.#versions[`${name}Term`] = config.version; + } registerTerm(config, this[$Internal]()); diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index ad555f3e..9d9fa331 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.6.1'; +export const TEMPO_VERSION = '3.7.0';