Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .agent/workflows/interactive-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ To use a NodeJS interactive session to test your Tempo library, you can use the

// turbo
```bash
npx tsx -i --import ./test/repl.ts
npx tsx --conditions=development -i --harmony-temporal --import ./bin/repl.ts
```


### Purpose
This command starts a Node.js REPL (Read-Eval-Print Loop) while pre-loading the `Tempo` class and the `Temporal` polyfill into the global scope. This allows you to try different invocations of `Tempo` directly without writing a script.
This command starts a Node.js REPL (Read-Eval-Print Loop) while pre-loading the `Tempo` class and the `Temporal` support into the global scope. This allows you to try different invocations of `Tempo` directly without writing a script.

### Usage Examples
Once the REPL has started, you can run commands like:
Expand All @@ -32,4 +32,4 @@ t1.add({ days: 5 }).format('plain');
### Why this works
- `npx tsx`: Uses the `tsx` runner to handle TypeScript files on the fly.
- `-i`: Explicitly requests an interactive session.
- `--import ./test/repl.ts`: Loads the helper script before starting the REPL, which attaches `Tempo` to `globalThis`.
- `--import ./bin/repl.ts`: Loads the helper script before starting the REPL, which attaches `Tempo` to `globalThis`.
35 changes: 0 additions & 35 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,39 +34,4 @@ jobs:
run: npm test
working-directory: packages/tempo

test-parse-prefilter:
name: Test with parse.preFilter enabled
runs-on: ubuntu-latest
timeout-minutes: 30
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
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- 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 }}

153 changes: 153 additions & 0 deletions doc/main_branch_protection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Preventing Writes to the Main Branch Locally

To prevent accidental writes (commits and pushes) to the `main` branch on your local workstation, you can use **Git Hooks**.


## Example: Local Hooks for Main Branch Protection
Set up local hooks to:
1. **`pre-commit`**: Prevent direct commits to the `main` branch.
2. **`pre-push`**: Prevent pushing changes to the remote `main` branch.

### How to Override
If you genuinely need to write to `main` (e.g., for an urgent fix), you have two options:
- **Environment Variable**: Prepend the command with the override flag:
- `ALLOW_MAIN_COMMIT=true git commit -m "Urgent fix"`
- `ALLOW_MAIN_PUSH=true git push`
- **Skip Hooks**: Use the standard Git flag:
- `git commit --no-verify`
- `git push --no-verify`

---

## Applying This Globally (Recommended)
If you want this protection to apply to **every repository** on your workstation, you can configure a global hooks directory.

### 1. Create a Global Hooks Directory
Choose a location (e.g., `~/.git-hooks`) and move the hook scripts there:

```bash
mkdir -p ~/.git-hooks
# Copy the hooks from your repository (replace /path/to/repo with your repo root or use the command below)
# Example using git to find the repo root:
cp $(git rev-parse --show-toplevel)/.git/hooks/pre-commit ~/.git-hooks/
cp $(git rev-parse --show-toplevel)/.git/hooks/pre-push ~/.git-hooks/
chmod +x ~/.git-hooks/*
```


### 2. Configure Git Globally (with Important Warning)
Run this command to tell Git to use your new global hooks directory:

```bash
git config --global core.hooksPath ~/.git-hooks
```

**⚠️ Warning:** Setting `core.hooksPath` with the `--global` flag disables all per-repository `.git/hooks/*` scripts. This will break tools that rely on per-project hooks, such as Husky, lefthook, lint-staged, and others. Any hooks defined in individual repositories will be ignored as long as the global `core.hooksPath` is set.

#### Recommended Alternatives

- **Chain per-repo hooks from your global hook scripts:**
- Manually update your global hook scripts (in `~/.git-hooks/`) to call any existing hooks in each repository’s `.git/hooks/` directory, so you don’t lose project-specific logic.
- **Scope the setting to individual repositories:**
- Instead of using `--global`, set the hooks path only for the current repository:
```bash
git config core.hooksPath ~/.git-hooks
```
- This way, only the current repo is affected, and other repos keep their own `.git/hooks/*` scripts.

For more details, see the [Git documentation on `core.hooksPath`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-corehookspath).

---

## Hook Implementation Details

### pre-commit
This script checks the current branch before every commit.

```bash
#!/bin/bash
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "main" ]; then
if [ "$ALLOW_MAIN_COMMIT" != "true" ]; then
echo "❌ ERROR: Direct commit to 'main' branch is prohibited."
exit 1
fi
fi
```

### pre-push
This script checks the remote branch being pushed to.

```bash
#!/bin/bash
while read local_ref local_sha remote_ref remote_sha
do
if [ "$remote_ref" = "refs/heads/main" ]; then
if [ "$ALLOW_MAIN_PUSH" != "true" ]; then
echo "❌ ERROR: Pushing to 'main' branch is prohibited."
exit 1
fi
fi
done
```

---

## 🆘 I'm on 'main' and have changes, what do I do?

If you've already made changes on `main` and the hook blocks your commit, **don't panic and don't drop your stash!**
1. **Stage all changes**: Run `git add .` or `git add -A` to ensure all changes, including new or untracked files, are staged for commit. (Note: `git commit -am` only stages modified files, so explicit staging is recommended to avoid losing new work).
2. **Commit your work**: Run `git commit -m "Your descriptive message"` to save your staged changes to your local branch history.

### The "Magic" Command: Just Create a New Branch
Git allows you to create and switch to a new branch while keeping your uncommitted changes.

```bash
# 1. Create and switch to a new branch
git checkout -b feature/my-cool-feature
# OR (modern syntax)
git switch -c feature/my-cool-feature

# 2. Now you can commit normally
git add .
git commit -m "My feature changes"
```

### If you want to be extra safe (The Stash Method)
If you have a lot of complex changes and want to ensure `main` stays clean:

```bash
# 1. Save your work temporarily
git stash

# 2. Create and switch to the new branch
git checkout -b feature/my-cool-feature

# 3. Bring your changes back
git stash pop

# 4. Commit
git add .
git commit -m "My feature changes"
```
Comment thread
magmacomputing marked this conversation as resolved.

### "I accidentally committed before I added the hook!"
If you have local commits on `main` that you haven't pushed yet, you can move them to a new branch:

```bash
# 1. Create a new branch at your current (accidental) commit
git branch feature/my-feature

# 2. Make sure your local reference to 'origin/main' is up-to-date
git fetch origin
# 3. Reset your local 'main' back to where it should be (the remote version)
# (Fetching first ensures you don't accidentally reset to a stale origin/main reference)
# SAFETY CHECK: Verify that your working tree is clean (e.g. `git status`).
# Stash or commit any uncommitted changes before proceeding!
# ⚠️ This reset will DISCARD ALL UNCOMMITTED LOCAL CHANGES.
git reset --hard origin/main

Comment thread
magmacomputing marked this conversation as resolved.
# 4. Switch to your new branch to continue working
git checkout feature/my-feature
```

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tempo-monorepo",
"version": "2.9.1",
"version": "2.9.2",
"private": true,
"description": "Magma Computing Monorepo",
"repository": {
Expand Down Expand Up @@ -41,4 +41,4 @@
"overrides": {
"esbuild": "^0.25.0"
}
}
}
2 changes: 1 addition & 1 deletion packages/library/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/library",
"version": "2.9.1",
"version": "2.9.2",
"description": "Shared utility library for Tempo",
"author": "Magma Computing Solutions",
"license": "MIT",
Expand Down
8 changes: 4 additions & 4 deletions packages/library/src/common/assertion.library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ export const isTemporal = <T>(obj: T): obj is Extract<T, Temporals> => protoType
(obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay
));

export const isInstant = <T>(obj: T): obj is Extract<T, Temporal.Instant> => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (!!obj && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId));
export const isZonedDateTime = <T>(obj: T): obj is Extract<T, Temporal.ZonedDateTime> => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (!!obj && typeof (obj as any).toInstant === 'function' && isDefined((obj as any).timeZoneId));
export const isPlainDate = <T>(obj: T): obj is Extract<T, Temporal.PlainDate> => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond));
export const isInstant = <T>(obj: T): obj is Extract<T, Temporal.Instant> => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (!!obj && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone));
export const isZonedDateTime = <T>(obj: T): obj is Extract<T, Temporal.ZonedDateTime> => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (!!obj && typeof (obj as any).toInstant === 'function' && (isDefined((obj as any).timeZoneId) || isDefined((obj as any).timeZone)));
export const isPlainDate = <T>(obj: T): obj is Extract<T, Temporal.PlainDate> => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond));
export const isPlainTime = <T>(obj: T): obj is Extract<T, Temporal.PlainTime> => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime') || (!!obj && typeof (obj as any).toPlainDateTime === 'function' && isUndefined((obj as any).daysInMonth));
export const isPlainDateTime = <T>(obj: T): obj is Extract<T, Temporal.PlainDateTime> => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond)));
export const isPlainDateTime = <T>(obj: T): obj is Extract<T, Temporal.PlainDateTime> => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isUndefined((obj as any).timeZone) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond)));
export const isDuration = <T>(obj: T): obj is Extract<T, Temporal.Duration> => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Duration');
export const isDurationLike = <T>(obj: T): obj is Extract<T, Temporal.DurationLike | string | Temporal.Duration> => isString(obj) || isDuration(obj) || (isObject(obj) && (
'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj ||
Expand Down
37 changes: 28 additions & 9 deletions packages/library/src/common/temporal.library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import '#library/temporal.polyfill.js'; // ensure Temporal is available
import { isNumber, isString } from '#library/assertion.library.js';
import { isNumber, isObject, isString, isUndefined, isZonedDateTime } from '#library/assertion.library.js';

/** return the current Temporal.Now.instant */
export function instant() {
Expand Down Expand Up @@ -81,9 +81,10 @@ export function normaliseFractionalDurations(payload: Record<string, any>) {
/**
* ## toZonedDateTime
* Create a `Temporal.ZonedDateTime` from a
* property-bag (year, month, day, …, timeZone, calendar).
* property-bag or ISO string.
*/
export function toZonedDateTime(bag: Temporal.ZonedDateTimeLike & { timeZone: Temporal.TimeZoneLike, calendar?: Temporal.CalendarLike }): Temporal.ZonedDateTime {
export function toZonedDateTime(bag: Temporal.ZonedDateTimeLike | string, tz: Temporal.TimeZoneLike = 'UTC'): Temporal.ZonedDateTime {
if (isString(bag)) return Temporal.ZonedDateTime.from(`${bag}[${tz}]`);
return Temporal.ZonedDateTime.from(bag);
}

Expand All @@ -108,15 +109,33 @@ export function toInstant(epochNanoseconds: bigint): Temporal.Instant {
/**
* ## getTemporalIds
* Normalize TimeZone and Calendar inputs into a [timeZoneId, calendarId] tuple.
* Accepts either (tz, cal) strings or a single ZonedDateTime-like object.
* Supports both spec-final (flat) and V8 harmony (nested) structures.
*/
export function getTemporalIds(tz: any, cal: any): [string, string] {
const rawTz = isString(tz) ? tz : ((tz as any)?.timeZoneId ?? (tz as any)?.id);
const rawCal = isString(cal) ? cal : ((cal as any)?.calendarId ?? (cal as any)?.id);
export function getTemporalIds(tzOrZdt: any, cal?: any): [string, string] {
const fallbackTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const tzId = (isString(rawTz) && rawTz.trim().length > 0) ? rawTz : fallbackTz;
const calId = (isString(rawCal) && rawCal.trim().length > 0) ? rawCal : 'iso8601';
const bag = (isZonedDateTime(tzOrZdt) && isUndefined(cal))
? tzOrZdt
: { timeZone: tzOrZdt, calendar: cal };

const rawTz = bag.timeZoneId ?? bag.timeZone?.id ?? bag.timeZone;
const rawCal = bag.calendarId ?? bag.calendar?.id ?? bag.calendar;

// Helper to extract string ID from potential objects (TimeZone, Calendar, or ZonedDateTime)
const toId = (v: any): string => {
if (isString(v)) return v;
if (isZonedDateTime(v)) return toId((v as any).timeZoneId ?? (v as any).timeZone?.id ?? (v as any).timeZone);
if (isObject(v)) return String((v as any).id ?? (v as any).timeZoneId ?? (v as any).calendarId ?? '');
return String(v ?? '');
}

const tzStr = toId(rawTz);
const calStr = toId(rawCal);

const tzId = (tzStr.trim().length > 0 && tzStr !== '[object Object]' && tzStr !== 'undefined') ? tzStr : fallbackTz;
const calId = (calStr.trim().length > 0 && calStr !== '[object Object]' && calStr !== 'undefined') ? calStr : 'iso8601';

return [tzId || 'UTC', calId || 'iso8601'];
return [tzId, calId];
}
Comment thread
magmacomputing marked this conversation as resolved.

/**
Expand Down
20 changes: 20 additions & 0 deletions packages/tempo/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.9.2] - 2026-05-07

### Added
- **Identity-Based Layout Resolution**: Hardened `resolveLayoutClassificationOrder` to support identity-based symbol lookups. This ensures that tokens without descriptions or aliases (such as raw symbols) can be correctly prioritized in preferred layout ordering.
- **Named Capture for Separators**: Updated the default `{sep}` snippet to use a named capture group `(?<sep>...)`, improving the inspectability of generated regex patterns.

### Changed
- **Modular Decompression**: Removed the redundant `parse.layout.ts` re-export module and consolidated all layout resolution logic into `engine.layout.ts`. Updated internal Specifiers and test-aliases to point to the new canonical home.
- **Node.js Harmony Support**: Updated documentation to highlight native `Temporal` support in Node.js 20+ via the `--harmony-temporal` flag, reducing the need for external polyfills in modern server-side environments.

### Fixed
- **Utility Security Hardening**: Refactored the `create<T>` and `setPatterns` utilities with robust prototype-shadowing guards. These improvements prevent `TypeError` crashes when interacting with null-prototype objects and guarantee `PatternCompiler` state isolation across concurrent Tempo instances.
- **RegExp Preview Accuracy**: Corrected the documentation example for `Tempo.regexp()` to accurately reflect the anchored outer capture group and named snippet expansions produced by the engine.

## [2.9.1] - 2026-05-07

### Fixed
- **Support Utility Consolidation**: Completed the rename and migration of internal support utilities to the `@packages/tempo/src/support/` directory.
- **Pattern Compiler isolated test state**: Fixed state-leakage in `pattern_compiler_optimization.test.ts` by implementing `TempoRuntime.createScoped()` and `init({}, false)` within `beforeEach` hooks.

## [2.9.0] - 2026-05-06

### Added
Expand Down
Loading