Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
56
packages/normalization-core/package.json
Normal file
56
packages/normalization-core/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@openclaw/normalization-core",
|
||||
"version": "0.0.0-private",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./boolean-coercion": {
|
||||
"types": "./dist/boolean-coercion.d.mts",
|
||||
"import": "./dist/boolean-coercion.mjs",
|
||||
"default": "./dist/boolean-coercion.mjs"
|
||||
},
|
||||
"./error-coercion": {
|
||||
"types": "./dist/error-coercion.d.mts",
|
||||
"import": "./dist/error-coercion.mjs",
|
||||
"default": "./dist/error-coercion.mjs"
|
||||
},
|
||||
"./number-coercion": {
|
||||
"types": "./dist/number-coercion.d.mts",
|
||||
"import": "./dist/number-coercion.mjs",
|
||||
"default": "./dist/number-coercion.mjs"
|
||||
},
|
||||
"./record-coerce": {
|
||||
"types": "./dist/record-coerce.d.mts",
|
||||
"import": "./dist/record-coerce.mjs",
|
||||
"default": "./dist/record-coerce.mjs"
|
||||
},
|
||||
"./string-coerce": {
|
||||
"types": "./dist/string-coerce.d.mts",
|
||||
"import": "./dist/string-coerce.mjs",
|
||||
"default": "./dist/string-coerce.mjs"
|
||||
},
|
||||
"./string-normalization": {
|
||||
"types": "./dist/string-normalization.d.mts",
|
||||
"import": "./dist/string-normalization.mjs",
|
||||
"default": "./dist/string-normalization.mjs"
|
||||
},
|
||||
"./utf16-slice": {
|
||||
"types": "./dist/utf16-slice.d.mts",
|
||||
"import": "./dist/utf16-slice.mjs",
|
||||
"default": "./dist/utf16-slice.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/boolean-coercion.ts src/error-coercion.ts src/number-coercion.ts src/record-coerce.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
}
|
||||
}
|
||||
22
packages/normalization-core/src/boolean-coercion.test.ts
Normal file
22
packages/normalization-core/src/boolean-coercion.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Normalization Core tests cover boolean coerce behavior.
|
||||
import { parseBoolean } from "@openclaw/normalization-core/boolean-coercion";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("normalization-core/boolean-coercion", () => {
|
||||
it.each([
|
||||
[true, true],
|
||||
[false, false],
|
||||
["true", true],
|
||||
[" FALSE ", false],
|
||||
["TrUe", true],
|
||||
])("parses %j as %s", (value, expected) => {
|
||||
expect(parseBoolean(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([undefined, null, 0, 1, "", "yes", "no", "on", "off", "1", "0"])(
|
||||
"rejects unsupported value %j",
|
||||
(value) => {
|
||||
expect(parseBoolean(value)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
17
packages/normalization-core/src/boolean-coercion.ts
Normal file
17
packages/normalization-core/src/boolean-coercion.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Parses booleans and case-insensitive `true`/`false` string tokens. */
|
||||
export function parseBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
18
packages/normalization-core/src/error-coercion.ts
Normal file
18
packages/normalization-core/src/error-coercion.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Normalizes an unknown thrown value into an Error. Non-Error objects become
|
||||
* the `cause` and have their enumerable fields copied so structured details
|
||||
* (codes, statuses) survive the coercion.
|
||||
*/
|
||||
export function toErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
10
packages/normalization-core/src/index.ts
Normal file
10
packages/normalization-core/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Public barrel for shared coercion and normalization helpers.
|
||||
|
||||
export * from "./boolean-coercion.js";
|
||||
export * from "./error-coercion.js";
|
||||
export * from "./json-coercion.js";
|
||||
export * from "./number-coercion.js";
|
||||
export * from "./record-coerce.js";
|
||||
export * from "./string-coerce.js";
|
||||
export * from "./string-normalization.js";
|
||||
export * from "./utf16-slice.js";
|
||||
12
packages/normalization-core/src/json-coercion.test.ts
Normal file
12
packages/normalization-core/src/json-coercion.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { safeParseJson } from "./json-coercion.js";
|
||||
|
||||
describe("json-coercion", () => {
|
||||
it.each<[string, unknown]>([
|
||||
['{"ok":true}', { ok: true }],
|
||||
["[1]", [1]],
|
||||
['"text"', "text"],
|
||||
["null", null],
|
||||
["{", undefined],
|
||||
])("parses %s", (value, expected) => expect(safeParseJson(value)).toEqual(expected));
|
||||
});
|
||||
8
packages/normalization-core/src/json-coercion.ts
Normal file
8
packages/normalization-core/src/json-coercion.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Parses JSON without throwing, returning undefined for invalid input. */
|
||||
export function safeParseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
238
packages/normalization-core/src/number-coercion.test.ts
Normal file
238
packages/normalization-core/src/number-coercion.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
// Normalization Core tests cover number coercion behavior.
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
asFiniteNumber,
|
||||
asFiniteNumberInRange,
|
||||
asSafeIntegerInRange,
|
||||
addTimerTimeoutGraceMs,
|
||||
clampPositiveTimerTimeoutMs,
|
||||
clampTimerTimeoutMs,
|
||||
finiteSecondsToTimerSafeMilliseconds,
|
||||
isFutureDateTimestampMs,
|
||||
MAX_TIMER_TIMEOUT_MS,
|
||||
MAX_TIMER_TIMEOUT_SECONDS,
|
||||
nonNegativeSecondsToSafeMilliseconds,
|
||||
parseFiniteNumber,
|
||||
positiveSecondsToSafeMilliseconds,
|
||||
resolveIntegerOption,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
resolveExpiresAtMsFromDurationOrEpoch,
|
||||
resolveExpiresAtMsFromEpochSeconds,
|
||||
resolveNonNegativeIntegerOption,
|
||||
resolveOptionalIntegerOption,
|
||||
resolvePositiveTimerTimeoutMs,
|
||||
resolveDateTimestampMs,
|
||||
parseStrictFiniteNumber,
|
||||
parseStrictInteger,
|
||||
parseStrictNonNegativeInteger,
|
||||
parseStrictPositiveInteger,
|
||||
resolveTimerTimeoutMs,
|
||||
resolveTimestampMsToIsoString,
|
||||
timestampMsToIsoFileStamp,
|
||||
timestampMsToIsoString,
|
||||
} from "./number-coercion.js";
|
||||
|
||||
describe("number-coercion", () => {
|
||||
test("asFiniteNumber accepts only finite numbers", () => {
|
||||
expect(asFiniteNumber(4)).toBe(4);
|
||||
expect(asFiniteNumber("4")).toBeUndefined();
|
||||
expect(asFiniteNumber(Number.NaN)).toBeUndefined();
|
||||
expect(asFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("asFiniteNumberInRange enforces inclusive and exclusive bounds", () => {
|
||||
expect(asFiniteNumberInRange(0.5, { min: 0.5, max: 2 })).toBe(0.5);
|
||||
expect(asFiniteNumberInRange(2, { min: 0.5, max: 2 })).toBe(2);
|
||||
expect(asFiniteNumberInRange(0.5, { min: 0.5, minExclusive: true })).toBeUndefined();
|
||||
expect(asFiniteNumberInRange(10, { max: 10, maxExclusive: true })).toBeUndefined();
|
||||
expect(asFiniteNumberInRange("1", { min: 0, max: 2 })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("asSafeIntegerInRange accepts only safe integers inside inclusive bounds", () => {
|
||||
expect(asSafeIntegerInRange(-1, { min: -1, max: 10 })).toBe(-1);
|
||||
expect(asSafeIntegerInRange(10, { min: -1, max: 10 })).toBe(10);
|
||||
expect(asSafeIntegerInRange(1.5, { min: -1, max: 10 })).toBeUndefined();
|
||||
expect(asSafeIntegerInRange(11, { min: -1, max: 10 })).toBeUndefined();
|
||||
expect(asSafeIntegerInRange(Number.NaN, { min: -1, max: 10 })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parseFiniteNumber accepts finite numbers and numeric strings", () => {
|
||||
expect(parseFiniteNumber(4)).toBe(4);
|
||||
expect(parseFiniteNumber("4.5")).toBe(4.5);
|
||||
expect(parseFiniteNumber("4.5ms")).toBeUndefined();
|
||||
expect(parseFiniteNumber("")).toBeUndefined();
|
||||
expect(parseFiniteNumber("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parseStrictInteger accepts only safe integer tokens", () => {
|
||||
expect(parseStrictInteger("42")).toBe(42);
|
||||
expect(parseStrictInteger(" -7 ")).toBe(-7);
|
||||
expect(parseStrictInteger("+9")).toBe(9);
|
||||
expect(parseStrictInteger("1.5")).toBeUndefined();
|
||||
expect(parseStrictInteger("1e3")).toBeUndefined();
|
||||
expect(parseStrictInteger(Number.MAX_SAFE_INTEGER + 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parseStrictFiniteNumber rejects partial numeric strings", () => {
|
||||
expect(parseStrictFiniteNumber("42")).toBe(42);
|
||||
expect(parseStrictFiniteNumber(".5")).toBe(0.5);
|
||||
expect(parseStrictFiniteNumber("1e3")).toBe(1000);
|
||||
expect(parseStrictFiniteNumber("3.14ms")).toBeUndefined();
|
||||
expect(parseStrictFiniteNumber("0x10")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("strict integer range helpers enforce sign", () => {
|
||||
expect(parseStrictPositiveInteger("9")).toBe(9);
|
||||
expect(parseStrictPositiveInteger("0")).toBeUndefined();
|
||||
expect(parseStrictNonNegativeInteger("0")).toBe(0);
|
||||
expect(parseStrictNonNegativeInteger("-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("timer timeout helpers centralize Node-safe bounds", () => {
|
||||
expect(MAX_TIMER_TIMEOUT_SECONDS).toBe(2_147_000);
|
||||
expect(finiteSecondsToTimerSafeMilliseconds(1.5)).toBe(1_500);
|
||||
expect(finiteSecondsToTimerSafeMilliseconds(1.5, { floorSeconds: true })).toBe(1_000);
|
||||
expect(finiteSecondsToTimerSafeMilliseconds(10_000_000)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(finiteSecondsToTimerSafeMilliseconds("10")).toBeUndefined();
|
||||
expect(finiteSecondsToTimerSafeMilliseconds(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
expect(clampTimerTimeoutMs(0, 10)).toBe(10);
|
||||
expect(clampTimerTimeoutMs(10_000_000_000)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(clampTimerTimeoutMs(Number.NaN)).toBeUndefined();
|
||||
expect(clampPositiveTimerTimeoutMs(0)).toBeUndefined();
|
||||
expect(clampPositiveTimerTimeoutMs(-1)).toBeUndefined();
|
||||
expect(clampPositiveTimerTimeoutMs(10_000_000_000)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(resolvePositiveTimerTimeoutMs(0, 5000)).toBe(5000);
|
||||
expect(resolvePositiveTimerTimeoutMs(Number.MAX_SAFE_INTEGER, 5000)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(resolveTimerTimeoutMs(Number.NaN, 5000)).toBe(5000);
|
||||
expect(resolveTimerTimeoutMs(Number.NaN, 0, 0)).toBe(0);
|
||||
expect(resolveTimerTimeoutMs(Number.NaN, Number.POSITIVE_INFINITY, 25)).toBe(25);
|
||||
expect(resolveTimerTimeoutMs(Number.MAX_SAFE_INTEGER, 5000)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(addTimerTimeoutGraceMs(10_000)).toBe(15_000);
|
||||
expect(addTimerTimeoutGraceMs(10_000, 500)).toBe(10_500);
|
||||
expect(addTimerTimeoutGraceMs(MAX_TIMER_TIMEOUT_MS - 100, 500)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(addTimerTimeoutGraceMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(addTimerTimeoutGraceMs(Number.MAX_VALUE)).toBe(MAX_TIMER_TIMEOUT_MS);
|
||||
expect(addTimerTimeoutGraceMs(Number.NaN)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("seconds helpers reject unsafe millisecond values", () => {
|
||||
expect(positiveSecondsToSafeMilliseconds("10")).toBe(10_000);
|
||||
expect(positiveSecondsToSafeMilliseconds("0")).toBeUndefined();
|
||||
expect(positiveSecondsToSafeMilliseconds("1e309")).toBeUndefined();
|
||||
expect(nonNegativeSecondsToSafeMilliseconds("0")).toBe(0);
|
||||
expect(nonNegativeSecondsToSafeMilliseconds("-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("timestamp ISO helper rejects Date-invalid timestamps", () => {
|
||||
expect(asDateTimestampMs(0)).toBe(0);
|
||||
expect(asDateTimestampMs(8_640_000_000_000_000)).toBe(8_640_000_000_000_000);
|
||||
expect(asDateTimestampMs(8_640_000_000_000_001)).toBeUndefined();
|
||||
expect(asDateTimestampMs(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
expect(asDateTimestampMs("0")).toBeUndefined();
|
||||
expect(timestampMsToIsoString(0)).toBe("1970-01-01T00:00:00.000Z");
|
||||
expect(timestampMsToIsoString(8_640_000_000_000_000)).toBe("+275760-09-13T00:00:00.000Z");
|
||||
expect(timestampMsToIsoString(8_640_000_000_000_001)).toBeUndefined();
|
||||
expect(timestampMsToIsoString(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
expect(timestampMsToIsoString("0")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("future timestamp helper rejects invalid Date timestamps", () => {
|
||||
expect(isFutureDateTimestampMs(1_001, { nowMs: 1_000 })).toBe(true);
|
||||
expect(isFutureDateTimestampMs(1_000, { nowMs: 1_000 })).toBe(false);
|
||||
expect(isFutureDateTimestampMs(999, { nowMs: 1_000 })).toBe(false);
|
||||
expect(isFutureDateTimestampMs(8_640_000_000_000_001, { nowMs: 1_000 })).toBe(false);
|
||||
expect(isFutureDateTimestampMs(1_001, { nowMs: Number.NaN })).toBe(false);
|
||||
});
|
||||
|
||||
test("timestamp fallback helpers resolve Date-invalid timestamps", () => {
|
||||
expect(resolveDateTimestampMs(1_000)).toBe(1_000);
|
||||
expect(resolveDateTimestampMs(Number.POSITIVE_INFINITY, 1_000)).toBe(1_000);
|
||||
expect(resolveDateTimestampMs(Number.POSITIVE_INFINITY, Number.NaN)).toBe(0);
|
||||
expect(resolveTimestampMsToIsoString(0)).toBe("1970-01-01T00:00:00.000Z");
|
||||
expect(resolveTimestampMsToIsoString(Number.POSITIVE_INFINITY, 1_000)).toBe(
|
||||
"1970-01-01T00:00:01.000Z",
|
||||
);
|
||||
expect(resolveTimestampMsToIsoString(Number.POSITIVE_INFINITY, Number.NaN)).toBe(
|
||||
"1970-01-01T00:00:00.000Z",
|
||||
);
|
||||
expect(timestampMsToIsoFileStamp(Date.parse("2026-02-23T12:34:56.000Z"))).toBe(
|
||||
"2026-02-23T12-34-56.000Z",
|
||||
);
|
||||
expect(timestampMsToIsoFileStamp(9_000_000_000_000_000, 1_000)).toBe(
|
||||
"1970-01-01T00-00-01.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
test("expiry helpers resolve safe absolute timestamps", () => {
|
||||
expect(
|
||||
resolveExpiresAtMsFromDurationMs(600_000, {
|
||||
nowMs: 1_000,
|
||||
}),
|
||||
).toBe(601_000);
|
||||
expect(
|
||||
resolveExpiresAtMsFromDurationMs(600_000, {
|
||||
nowMs: 8_640_000_000_000_000,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveExpiresAtMsFromDurationMs(600_000, {
|
||||
nowMs: 8_640_000_000_000_001,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveExpiresAtMsFromDurationSeconds("3600", {
|
||||
nowMs: 1_000,
|
||||
bufferMs: 300,
|
||||
}),
|
||||
).toBe(3_600_700);
|
||||
expect(
|
||||
resolveExpiresAtMsFromDurationSeconds("10", {
|
||||
nowMs: 1_000,
|
||||
bufferMs: 20_000,
|
||||
minRemainingMs: 30_000,
|
||||
}),
|
||||
).toBe(31_000);
|
||||
expect(
|
||||
resolveExpiresAtMsFromDurationSeconds("3600", {
|
||||
nowMs: 8_640_000_000_000_000,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(resolveExpiresAtMsFromDurationSeconds("1e309", { nowMs: 1_000 })).toBeUndefined();
|
||||
expect(resolveExpiresAtMsFromEpochSeconds(1234.9)).toBe(1_234_000);
|
||||
expect(resolveExpiresAtMsFromEpochSeconds("3600", { bufferMs: 300 })).toBe(3_599_700);
|
||||
expect(resolveExpiresAtMsFromEpochSeconds("100", { maxMs: 99_999 })).toBeUndefined();
|
||||
expect(resolveExpiresAtMsFromEpochSeconds(Number.MAX_SAFE_INTEGER)).toBeUndefined();
|
||||
expect(resolveExpiresAtMsFromEpochSeconds(8_640_000_000_001)).toBeUndefined();
|
||||
expect(resolveExpiresAtMsFromEpochSeconds("1e309")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("mixed expiry helper handles relative seconds, epoch seconds, and absolute milliseconds", () => {
|
||||
expect(resolveExpiresAtMsFromDurationOrEpoch(86_400, { nowMs: 1_700_000_000_000 })).toBe(
|
||||
1_700_086_400_000,
|
||||
);
|
||||
expect(resolveExpiresAtMsFromDurationOrEpoch(1_700_000_000)).toBe(1_700_000_000_000);
|
||||
expect(resolveExpiresAtMsFromDurationOrEpoch(1_700_000_000_000)).toBe(1_700_000_000_000);
|
||||
expect(resolveExpiresAtMsFromDurationOrEpoch(8_640_000_000_000_001)).toBeUndefined();
|
||||
expect(resolveExpiresAtMsFromDurationOrEpoch(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
expect(resolveExpiresAtMsFromDurationOrEpoch(Number.MAX_SAFE_INTEGER + 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("integer option helpers floor finite values and fall back for non-finite values", () => {
|
||||
expect(resolveIntegerOption(7.9, 1, { min: 1, max: 10 })).toBe(7);
|
||||
expect(resolveIntegerOption(Number.NaN, 4.9, { min: 1 })).toBe(4);
|
||||
expect(resolveIntegerOption(Number.NEGATIVE_INFINITY, 4, { min: 1 })).toBe(4);
|
||||
expect(resolveIntegerOption(-4, 1, { min: 0 })).toBe(0);
|
||||
expect(resolveIntegerOption(40, 1, { max: 10 })).toBe(10);
|
||||
expect(resolveNonNegativeIntegerOption(Number.NaN, 3.9)).toBe(3);
|
||||
});
|
||||
|
||||
test("optional integer option helper rejects non-finite values", () => {
|
||||
expect(resolveOptionalIntegerOption(7.9, { min: 1, max: 10 })).toBe(7);
|
||||
expect(resolveOptionalIntegerOption(Number.NaN, { min: 1 })).toBeUndefined();
|
||||
expect(resolveOptionalIntegerOption(Number.POSITIVE_INFINITY, { min: 1 })).toBeUndefined();
|
||||
expect(resolveOptionalIntegerOption(-4, { min: 0 })).toBe(0);
|
||||
expect(resolveOptionalIntegerOption(40, { max: 10 })).toBe(10);
|
||||
});
|
||||
});
|
||||
373
packages/normalization-core/src/number-coercion.ts
Normal file
373
packages/normalization-core/src/number-coercion.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/** Returns a number only when the input is already finite. */
|
||||
export function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/** Returns a finite number only when it satisfies the supplied inclusive/exclusive bounds. */
|
||||
export function asFiniteNumberInRange(
|
||||
value: unknown,
|
||||
range: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minExclusive?: boolean;
|
||||
maxExclusive?: boolean;
|
||||
},
|
||||
): number | undefined {
|
||||
const number = asFiniteNumber(value);
|
||||
if (number === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (range.min !== undefined) {
|
||||
if (range.minExclusive ? number <= range.min : number < range.min) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (range.max !== undefined) {
|
||||
if (range.maxExclusive ? number >= range.max : number > range.max) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
/** Returns a safe integer only when it satisfies the supplied inclusive bounds. */
|
||||
export function asSafeIntegerInRange(
|
||||
value: unknown,
|
||||
range: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
},
|
||||
): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (range.min !== undefined && value < range.min) {
|
||||
return undefined;
|
||||
}
|
||||
if (range.max !== undefined && value > range.max) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeNumericString(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/** Parses finite numbers from number values or strict numeric string tokens. */
|
||||
export function parseFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
return parseStrictFiniteNumber(value);
|
||||
}
|
||||
|
||||
/** Parses only safe integer numbers or base-10 integer strings. */
|
||||
export function parseStrictInteger(value: unknown): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isSafeInteger(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeNumericString(value);
|
||||
if (!normalized || !/^[+-]?\d+$/.test(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(normalized);
|
||||
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** Parses only finite decimal/scientific string tokens, rejecting partial numbers. */
|
||||
export function parseStrictFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeNumericString(value);
|
||||
if (!normalized || !/^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/i.test(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** Returns positive safe integers without string coercion. */
|
||||
export function asPositiveSafeInteger(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Conservative upper bound for Node timer delays. */
|
||||
export const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
|
||||
/** Timer bound expressed in whole seconds for env/config inputs. */
|
||||
export const MAX_TIMER_TIMEOUT_SECONDS = Math.floor(MAX_TIMER_TIMEOUT_MS / 1000);
|
||||
/** Largest timestamp accepted by JavaScript Date. */
|
||||
export const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
|
||||
/** Fallback ISO value for invalid timestamp inputs. */
|
||||
export const UNIX_EPOCH_ISO_STRING = "1970-01-01T00:00:00.000Z";
|
||||
|
||||
/** Returns a Date-valid millisecond timestamp. */
|
||||
export function asDateTimestampMs(value: unknown): number | undefined {
|
||||
return asFiniteNumberInRange(value, {
|
||||
min: -MAX_DATE_TIMESTAMP_MS,
|
||||
max: MAX_DATE_TIMESTAMP_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/** Checks whether a Date-valid timestamp is after the supplied/current time. */
|
||||
export function isFutureDateTimestampMs(
|
||||
value: unknown,
|
||||
opts: { nowMs?: number } = {},
|
||||
): value is number {
|
||||
const timestampMs = asDateTimestampMs(value);
|
||||
const nowMs = asDateTimestampMs(opts.nowMs ?? Date.now());
|
||||
return timestampMs !== undefined && nowMs !== undefined && timestampMs > nowMs;
|
||||
}
|
||||
|
||||
/** Converts Date-valid millisecond timestamps to ISO strings. */
|
||||
export function timestampMsToIsoString(value: unknown): string | undefined {
|
||||
const timestampMs = asDateTimestampMs(value);
|
||||
return timestampMs === undefined ? undefined : new Date(timestampMs).toISOString();
|
||||
}
|
||||
|
||||
/** Resolves a Date-valid timestamp with a Date-valid fallback. */
|
||||
export function resolveDateTimestampMs(
|
||||
value: unknown,
|
||||
fallbackValue: unknown = Date.now(),
|
||||
): number {
|
||||
return asDateTimestampMs(value) ?? asDateTimestampMs(fallbackValue) ?? 0;
|
||||
}
|
||||
|
||||
/** Resolves a Date-valid timestamp to ISO, falling back to Unix epoch if needed. */
|
||||
export function resolveTimestampMsToIsoString(
|
||||
value: unknown,
|
||||
fallbackValue: unknown = Date.now(),
|
||||
): string {
|
||||
return (
|
||||
timestampMsToIsoString(value) ?? timestampMsToIsoString(fallbackValue) ?? UNIX_EPOCH_ISO_STRING
|
||||
);
|
||||
}
|
||||
|
||||
/** Formats Date-valid timestamps for filenames by replacing colon separators. */
|
||||
export function timestampMsToIsoFileStamp(
|
||||
value: unknown,
|
||||
fallbackValue: unknown = Date.now(),
|
||||
): string {
|
||||
return resolveTimestampMsToIsoString(value, fallbackValue).replaceAll(":", "-");
|
||||
}
|
||||
|
||||
/** Clamps finite millisecond values into the Node-safe timer range. */
|
||||
export function clampTimerTimeoutMs(valueMs: unknown, minMs = 1): number | undefined {
|
||||
const value = asFiniteNumber(valueMs);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const min = Math.max(1, Math.floor(minMs));
|
||||
return Math.min(Math.max(Math.floor(value), min), MAX_TIMER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** Clamps positive finite millisecond values into the Node-safe timer range. */
|
||||
export function clampPositiveTimerTimeoutMs(valueMs: unknown): number | undefined {
|
||||
const value = asFiniteNumber(valueMs);
|
||||
if (value === undefined || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return clampTimerTimeoutMs(value);
|
||||
}
|
||||
|
||||
/** Resolves a positive timer timeout or falls back through safe timer clamping. */
|
||||
export function resolvePositiveTimerTimeoutMs(valueMs: unknown, fallbackMs: number): number {
|
||||
return clampPositiveTimerTimeoutMs(valueMs) ?? resolveTimerTimeoutMs(fallbackMs, 1);
|
||||
}
|
||||
|
||||
/** Resolves arbitrary timeout input with fallback and minimum timer bounds. */
|
||||
export function resolveTimerTimeoutMs(valueMs: unknown, fallbackMs: number, minMs = 1): number {
|
||||
const value = asFiniteNumber(valueMs) ?? asFiniteNumber(fallbackMs);
|
||||
const min = Math.max(0, Math.floor(minMs));
|
||||
if (value === undefined) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(Math.max(Math.floor(value), min), MAX_TIMER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** Adds grace time to a finite timeout and clamps the result to Node-safe bounds. */
|
||||
export function addTimerTimeoutGraceMs(timeoutMs: unknown, graceMs = 5_000): number | undefined {
|
||||
const timeout = asFiniteNumber(timeoutMs);
|
||||
const grace = asFiniteNumber(graceMs);
|
||||
if (timeout === undefined || grace === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const withGrace = timeout + grace;
|
||||
return Number.isFinite(withGrace) ? clampTimerTimeoutMs(withGrace) : MAX_TIMER_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** Converts finite positive seconds to Node-safe milliseconds. */
|
||||
export function finiteSecondsToTimerSafeMilliseconds(
|
||||
value: unknown,
|
||||
opts: { floorSeconds?: boolean } = {},
|
||||
): number | undefined {
|
||||
const seconds = asFiniteNumber(value);
|
||||
if (seconds === undefined || seconds <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const boundedSeconds = opts.floorSeconds ? Math.floor(seconds) : seconds;
|
||||
const milliseconds = Math.floor(boundedSeconds * 1000);
|
||||
if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.min(milliseconds, MAX_TIMER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** Resolves an integer option from finite numeric input or fallback, then clamps bounds. */
|
||||
export function resolveIntegerOption(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
range: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
} = {},
|
||||
): number {
|
||||
const candidate = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
const floored = Math.floor(candidate);
|
||||
const minBounded = range.min === undefined ? floored : Math.max(range.min, floored);
|
||||
return range.max === undefined ? minBounded : Math.min(range.max, minBounded);
|
||||
}
|
||||
|
||||
/** Resolves an optional integer option, returning undefined for non-finite input. */
|
||||
export function resolveOptionalIntegerOption(
|
||||
value: unknown,
|
||||
range: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
} = {},
|
||||
): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveIntegerOption(value, value, range);
|
||||
}
|
||||
|
||||
/** Resolves an integer option with a non-negative lower bound. */
|
||||
export function resolveNonNegativeIntegerOption(value: unknown, fallback: number): number {
|
||||
return resolveIntegerOption(value, fallback, { min: 0 });
|
||||
}
|
||||
|
||||
/** Parses strict positive integer values from numbers or strings. */
|
||||
export function parseStrictPositiveInteger(value: unknown): number | undefined {
|
||||
const parsed = parseStrictInteger(value);
|
||||
return parsed !== undefined && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** Parses strict non-negative integer values from numbers or strings. */
|
||||
export function parseStrictNonNegativeInteger(value: unknown): number | undefined {
|
||||
const parsed = parseStrictInteger(value);
|
||||
return parsed !== undefined && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** Converts strict positive seconds to safe millisecond counts. */
|
||||
export function positiveSecondsToSafeMilliseconds(value: unknown): number | undefined {
|
||||
const seconds = parseStrictPositiveInteger(value);
|
||||
if (seconds === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const milliseconds = seconds * 1000;
|
||||
return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
|
||||
}
|
||||
|
||||
/** Converts strict non-negative seconds to safe millisecond counts. */
|
||||
export function nonNegativeSecondsToSafeMilliseconds(value: unknown): number | undefined {
|
||||
const seconds = parseStrictNonNegativeInteger(value);
|
||||
if (seconds === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const milliseconds = seconds * 1000;
|
||||
return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
|
||||
}
|
||||
|
||||
/** Resolves an absolute expiration timestamp from a positive duration in milliseconds. */
|
||||
export function resolveExpiresAtMsFromDurationMs(
|
||||
value: unknown,
|
||||
opts: { nowMs?: number; bufferMs?: number; minRemainingMs?: number } = {},
|
||||
): number | undefined {
|
||||
const durationMs = asPositiveSafeInteger(value);
|
||||
if (durationMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const nowMs = asDateTimestampMs(opts.nowMs ?? Date.now());
|
||||
const bufferMs = asFiniteNumber(opts.bufferMs ?? 0);
|
||||
if (nowMs === undefined || bufferMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const expiresAt = nowMs + durationMs - bufferMs;
|
||||
if (!Number.isSafeInteger(expiresAt) || timestampMsToIsoString(expiresAt) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const minRemainingMs = opts.minRemainingMs;
|
||||
if (minRemainingMs === undefined) {
|
||||
return expiresAt;
|
||||
}
|
||||
const minExpiresAt = nowMs + minRemainingMs;
|
||||
if (!Number.isSafeInteger(minExpiresAt) || timestampMsToIsoString(minExpiresAt) === undefined) {
|
||||
return expiresAt;
|
||||
}
|
||||
return Math.max(expiresAt, minExpiresAt);
|
||||
}
|
||||
|
||||
/** Resolves an absolute expiration timestamp from a positive duration in seconds. */
|
||||
export function resolveExpiresAtMsFromDurationSeconds(
|
||||
value: unknown,
|
||||
opts: { nowMs?: number; bufferMs?: number; minRemainingMs?: number } = {},
|
||||
): number | undefined {
|
||||
const durationMs = positiveSecondsToSafeMilliseconds(value);
|
||||
return durationMs === undefined ? undefined : resolveExpiresAtMsFromDurationMs(durationMs, opts);
|
||||
}
|
||||
|
||||
/** Resolves an absolute expiration timestamp from Unix epoch seconds. */
|
||||
export function resolveExpiresAtMsFromEpochSeconds(
|
||||
value: unknown,
|
||||
opts: { bufferMs?: number; maxMs?: number } = {},
|
||||
): number | undefined {
|
||||
const epochMs =
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.trunc(value) * 1000
|
||||
: positiveSecondsToSafeMilliseconds(value);
|
||||
if (epochMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const expiresAt = epochMs - (opts.bufferMs ?? 0);
|
||||
if (!Number.isSafeInteger(expiresAt)) {
|
||||
return undefined;
|
||||
}
|
||||
if (timestampMsToIsoString(expiresAt) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const maxMs = opts.maxMs;
|
||||
return maxMs === undefined || expiresAt <= maxMs ? expiresAt : undefined;
|
||||
}
|
||||
|
||||
/** Resolves expiration input that may be relative seconds, epoch seconds, or epoch milliseconds. */
|
||||
export function resolveExpiresAtMsFromDurationOrEpoch(
|
||||
value: unknown,
|
||||
opts: {
|
||||
nowMs?: number;
|
||||
relativeSecondsThreshold?: number;
|
||||
absoluteMillisecondsThreshold?: number;
|
||||
} = {},
|
||||
): number | undefined {
|
||||
const parsed = parseStrictPositiveInteger(value);
|
||||
if (parsed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const relativeSecondsThreshold = opts.relativeSecondsThreshold ?? 1_000_000_000;
|
||||
if (parsed < relativeSecondsThreshold) {
|
||||
return resolveExpiresAtMsFromDurationSeconds(parsed, { nowMs: opts.nowMs });
|
||||
}
|
||||
const absoluteMillisecondsThreshold = opts.absoluteMillisecondsThreshold ?? 1_000_000_000_000;
|
||||
if (parsed < absoluteMillisecondsThreshold) {
|
||||
return resolveExpiresAtMsFromEpochSeconds(parsed);
|
||||
}
|
||||
return asDateTimestampMs(parsed);
|
||||
}
|
||||
25
packages/normalization-core/src/record-coerce.test.ts
Normal file
25
packages/normalization-core/src/record-coerce.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Normalization Core tests cover record coerce behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { asNullableRecord, asOptionalRecord } from "./record-coerce.js";
|
||||
|
||||
describe("record-coerce", () => {
|
||||
it("keeps record coercion behavior for optional and nullable variants", () => {
|
||||
expect(asOptionalRecord({ ok: true })).toEqual({ ok: true });
|
||||
expect(asOptionalRecord(null)).toBeUndefined();
|
||||
expect(asOptionalRecord([{ ok: true }])).toBeUndefined();
|
||||
expect(asNullableRecord({ ok: true })).toEqual({ ok: true });
|
||||
expect(asNullableRecord(null)).toBeNull();
|
||||
expect(asNullableRecord([{ ok: true }])).toBeNull();
|
||||
});
|
||||
|
||||
it("stays isolated from utils.ts so browser bundles stay Node-free", () => {
|
||||
const source = readFileSync(
|
||||
path.resolve("packages/normalization-core/src/record-coerce.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toContain("../utils.js");
|
||||
});
|
||||
});
|
||||
38
packages/normalization-core/src/record-coerce.ts
Normal file
38
packages/normalization-core/src/record-coerce.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Type guard for non-array object records at browser-safe boundaries. */
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Coerces object-like values to records, falling back to an empty record. */
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** Reads a field only when it exists as a string. */
|
||||
export function readStringField(
|
||||
record: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** Returns a non-array record or undefined. */
|
||||
export function asOptionalRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/** Returns a non-array record or null. */
|
||||
export function asNullableRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Returns any object-backed record, including arrays, or undefined. */
|
||||
export function asOptionalObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
/** Returns any object-backed record, including arrays, or null. */
|
||||
export function asNullableObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
15
packages/normalization-core/src/string-coerce.test.ts
Normal file
15
packages/normalization-core/src/string-coerce.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Normalization Core tests cover string coerce behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeStringifiedEntries } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
describe("normalization-core/string-coerce", () => {
|
||||
it("normalizes primitive stringified entries", () => {
|
||||
expect(normalizeStringifiedEntries([" a ", 42, true, 0n, "", " ", null, {}])).toEqual([
|
||||
"a",
|
||||
"42",
|
||||
"true",
|
||||
"0",
|
||||
]);
|
||||
expect(normalizeStringifiedEntries(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
109
packages/normalization-core/src/string-coerce.ts
Normal file
109
packages/normalization-core/src/string-coerce.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/** Reads a value only when it is already a string, preserving whitespace. */
|
||||
export function readStringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** Trims string input and returns null for non-strings or empty strings. */
|
||||
export function normalizeNullableString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** Trims string input and returns undefined for non-strings or empty strings. */
|
||||
export function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return normalizeNullableString(value) ?? undefined;
|
||||
}
|
||||
|
||||
/** Stringifies primitive ids/flags before applying optional string normalization. */
|
||||
export function normalizeStringifiedOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return normalizeOptionalString(String(value));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Normalizes an optional array of primitive-ish values into non-empty strings. */
|
||||
export function normalizeStringifiedEntries(values?: ReadonlyArray<unknown>): string[] {
|
||||
return (values ?? [])
|
||||
.map((entry) => normalizeStringifiedOptionalString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
/** Lowercases a normalized optional string. */
|
||||
export function normalizeOptionalLowercaseString(value: unknown): string | undefined {
|
||||
return normalizeOptionalString(value)?.toLowerCase();
|
||||
}
|
||||
|
||||
/** Lowercases a normalized string or returns an empty string when absent. */
|
||||
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return normalizeOptionalLowercaseString(value) ?? "";
|
||||
}
|
||||
|
||||
export type FastMode = boolean | "auto";
|
||||
|
||||
/** Parses loose boolean/fast-mode flags from strings or booleans. */
|
||||
export function normalizeFastMode(raw?: unknown): FastMode | undefined {
|
||||
if (typeof raw === "boolean") {
|
||||
return raw;
|
||||
}
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (["off", "false", "no", "0", "disable", "disabled", "normal"].includes(key)) {
|
||||
return false;
|
||||
}
|
||||
if (["on", "true", "yes", "1", "enable", "enabled", "fast"].includes(key)) {
|
||||
return true;
|
||||
}
|
||||
if (["auto", "automatic"].includes(key)) {
|
||||
return "auto";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Lowercases text while intentionally preserving surrounding whitespace. */
|
||||
export function lowercasePreservingWhitespace(value: string): string {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
/** Locale-aware lowercase helper that still preserves surrounding whitespace. */
|
||||
export function localeLowercasePreservingWhitespace(value: string): string {
|
||||
return value.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
/** Reads a string directly or from an object's `primary` field. */
|
||||
export function resolvePrimaryStringValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeOptionalString((value as { primary?: unknown }).primary);
|
||||
}
|
||||
|
||||
/** Normalizes thread ids that may be numeric or string-backed. */
|
||||
export function normalizeOptionalThreadValue(value: unknown): string | number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? Math.trunc(value) : undefined;
|
||||
}
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
/** Normalizes a thread/id value and stringifies finite numeric ids. */
|
||||
export function normalizeOptionalStringifiedId(value: unknown): string | undefined {
|
||||
const normalized = normalizeOptionalThreadValue(value);
|
||||
return normalized == null ? undefined : String(normalized);
|
||||
}
|
||||
|
||||
/** Type guard for strings that remain non-empty after trimming. */
|
||||
export function hasNonEmptyString(value: unknown): value is string {
|
||||
return normalizeOptionalString(value) !== undefined;
|
||||
}
|
||||
143
packages/normalization-core/src/string-normalization.test.ts
Normal file
143
packages/normalization-core/src/string-normalization.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Normalization Core tests cover string normalization behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeAtHashSlug,
|
||||
normalizeHyphenSlug,
|
||||
normalizeSortedUniqueStringEntries,
|
||||
normalizeSortedUniqueTrimmedStringList,
|
||||
normalizeStringEntries,
|
||||
normalizeStringEntriesLower,
|
||||
normalizeUniqueSingleOrTrimmedStringList,
|
||||
normalizeUniqueStringEntries,
|
||||
normalizeUniqueStringEntriesLower,
|
||||
normalizeUniqueTrimmedStringList,
|
||||
sortUniqueStrings,
|
||||
uniqueStrings,
|
||||
} from "./string-normalization.js";
|
||||
|
||||
describe("normalization-core/string-normalization", () => {
|
||||
it("normalizes mixed allow-list entries", () => {
|
||||
expect(normalizeStringEntries([" a ", 42, "", " ", "z"])).toEqual(["a", "42", "z"]);
|
||||
expect(normalizeStringEntries([" ok ", null, { toString: () => " obj " }])).toEqual([
|
||||
"ok",
|
||||
"null",
|
||||
"obj",
|
||||
]);
|
||||
expect(normalizeStringEntries(undefined)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes mixed allow-list entries to lowercase", () => {
|
||||
expect(normalizeStringEntriesLower([" A ", "MiXeD", 7])).toEqual(["a", "mixed", "7"]);
|
||||
});
|
||||
|
||||
it("sorts unique string values", () => {
|
||||
expect(sortUniqueStrings(["b", "a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("deduplicates string values while preserving first-seen order", () => {
|
||||
expect(uniqueStrings(["b", "a", "b", "c", "a"])).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("normalizes unique string entries", () => {
|
||||
expect(normalizeUniqueStringEntries([" b ", "a", "b", "", 4, "a"])).toEqual(["b", "a", "4"]);
|
||||
});
|
||||
|
||||
it("normalizes unique lowercase string entries", () => {
|
||||
expect(normalizeUniqueStringEntriesLower([" A ", "a", "MiXeD", "", 7])).toEqual([
|
||||
"a",
|
||||
"mixed",
|
||||
"7",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes sorted unique string entries", () => {
|
||||
expect(normalizeSortedUniqueStringEntries([" b ", "a", "b", "", 4])).toEqual(["4", "a", "b"]);
|
||||
});
|
||||
|
||||
it("normalizes unique trimmed string lists", () => {
|
||||
expect(normalizeUniqueTrimmedStringList([" b ", "a", "b", "", "a"])).toEqual(["b", "a"]);
|
||||
expect(normalizeUniqueTrimmedStringList("b")).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes sorted unique trimmed string lists", () => {
|
||||
expect(normalizeSortedUniqueTrimmedStringList([" b ", "a", "b", "", "a"])).toEqual(["a", "b"]);
|
||||
expect(normalizeSortedUniqueTrimmedStringList(["z", 1, " a "] as unknown[])).toEqual([
|
||||
"a",
|
||||
"z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes unique single-or-list string values", () => {
|
||||
expect(normalizeUniqueSingleOrTrimmedStringList([" b ", "a", "b", "", "a"])).toEqual([
|
||||
"b",
|
||||
"a",
|
||||
]);
|
||||
expect(normalizeUniqueSingleOrTrimmedStringList(" b ")).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("normalizes slug-like labels while preserving supported symbols", () => {
|
||||
expect(normalizeHyphenSlug(" Team Room ")).toBe("team-room");
|
||||
expect(normalizeHyphenSlug(" #My_Channel + Alerts ")).toBe("#my_channel-+-alerts");
|
||||
expect(normalizeHyphenSlug("..foo---bar..")).toBe("foo-bar");
|
||||
expect(normalizeHyphenSlug(undefined)).toBe("");
|
||||
expect(normalizeHyphenSlug(null)).toBe("");
|
||||
});
|
||||
|
||||
it("collapses repeated separators and trims leading/trailing punctuation", () => {
|
||||
expect(normalizeHyphenSlug(" ...Hello / World--- ")).toBe("hello-world");
|
||||
expect(normalizeHyphenSlug(" ###Team@@@Room### ")).toBe("###team@@@room###");
|
||||
});
|
||||
|
||||
it("normalizes @/# prefixed slugs used by channel allowlists", () => {
|
||||
expect(normalizeAtHashSlug(" #My_Channel + Alerts ")).toBe("my-channel-alerts");
|
||||
expect(normalizeAtHashSlug("@@Room___Name")).toBe("room-name");
|
||||
expect(normalizeAtHashSlug(undefined)).toBe("");
|
||||
expect(normalizeAtHashSlug(null)).toBe("");
|
||||
});
|
||||
|
||||
it("strips repeated prefixes and collapses separator-only results", () => {
|
||||
expect(normalizeAtHashSlug("###__Room Name__")).toBe("room-name");
|
||||
expect(normalizeAtHashSlug("@@@___")).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["技术讨论组", "技术讨论组"],
|
||||
[" AI 助手群 ", "ai-助手群"],
|
||||
["友達グループ", "友達グループ"],
|
||||
["개발자 모임", "개발자-모임"],
|
||||
["Team 技术讨论", "team-技术讨论"],
|
||||
["#OpenClaw中文群", "#openclaw中文群"],
|
||||
["Команда разработки", "команда-разработки"],
|
||||
["فريق التطوير", "فريق-التطوير"],
|
||||
])("preserves Unicode letters in normalizeHyphenSlug: %s", (input, expected) => {
|
||||
expect(normalizeHyphenSlug(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Cafe\u0301 Team", "café-team"],
|
||||
["हिन्दी चर्चा", "हिन्दी-चर्चा"],
|
||||
["ห้อง แช็ต", "ห้อง-แช็ต"],
|
||||
])("preserves combining marks in normalizeHyphenSlug: %s", (input, expected) => {
|
||||
expect(normalizeHyphenSlug(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["#技术频道", "技术频道"],
|
||||
["@中文群组", "中文群组"],
|
||||
["#日本語チャンネル", "日本語チャンネル"],
|
||||
["#한국어채널", "한국어채널"],
|
||||
["#Команда разработки", "команда-разработки"],
|
||||
["@فريق التطوير", "فريق-التطوير"],
|
||||
["#OpenClaw中文群", "openclaw中文群"],
|
||||
])("preserves Unicode letters in normalizeAtHashSlug: %s", (input, expected) => {
|
||||
expect(normalizeAtHashSlug(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["#Cafe\u0301_Team", "café-team"],
|
||||
["@हिन्दी चर्चा", "हिन्दी-चर्चा"],
|
||||
["#ห้อง แช็ต", "ห้อง-แช็ต"],
|
||||
])("preserves combining marks in normalizeAtHashSlug: %s", (input, expected) => {
|
||||
expect(normalizeAtHashSlug(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
138
packages/normalization-core/src/string-normalization.ts
Normal file
138
packages/normalization-core/src/string-normalization.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// Normalization Core module implements string normalization behavior.
|
||||
import { normalizeOptionalLowercaseString, normalizeOptionalString } from "./string-coerce.js";
|
||||
|
||||
/** Coerces entries to strings, trims them, and drops empty results. */
|
||||
export function normalizeStringEntries(list?: ReadonlyArray<unknown>) {
|
||||
return (list ?? []).map((entry) => normalizeOptionalString(String(entry)) ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
/** Normalizes string entries and lowercases each retained value. */
|
||||
export function normalizeStringEntriesLower(list?: ReadonlyArray<unknown>) {
|
||||
return normalizeStringEntries(list).map((entry) => normalizeOptionalLowercaseString(entry) ?? "");
|
||||
}
|
||||
|
||||
/** Returns first-seen unique values while preserving insertion order. */
|
||||
export function uniqueValues<T>(values: Iterable<T>): T[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
/** Returns first-seen unique strings while preserving insertion order. */
|
||||
export function uniqueStrings(values: Iterable<string>): string[] {
|
||||
return uniqueValues(values);
|
||||
}
|
||||
|
||||
/** Returns unique strings sorted with stable ASCII comparison. */
|
||||
export function sortUniqueStrings(values: Iterable<string>): string[] {
|
||||
return uniqueStrings(values).toSorted((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes entries, removes duplicates, and preserves first-seen order. */
|
||||
export function normalizeUniqueStringEntries(values?: Iterable<unknown>): string[] {
|
||||
return uniqueStrings(normalizeStringEntries(values ? [...values] : undefined));
|
||||
}
|
||||
|
||||
/** Lowercases normalized entries, removes empties/duplicates, and preserves first-seen order. */
|
||||
export function normalizeUniqueStringEntriesLower(values?: Iterable<unknown>): string[] {
|
||||
return uniqueStrings(
|
||||
normalizeStringEntriesLower(values ? [...values] : undefined).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes entries, removes duplicates, and returns sorted output. */
|
||||
export function normalizeSortedUniqueStringEntries(values?: Iterable<unknown>): string[] {
|
||||
return sortUniqueStrings(normalizeUniqueStringEntries(values));
|
||||
}
|
||||
|
||||
/** Normalizes array-backed string lists and rejects non-array input as empty. */
|
||||
export function normalizeTrimmedStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((entry) => {
|
||||
const normalized = normalizeOptionalString(entry);
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalizes an array-backed string list and removes duplicates. */
|
||||
export function normalizeUniqueTrimmedStringList(value: unknown): string[] {
|
||||
return uniqueStrings(normalizeTrimmedStringList(value));
|
||||
}
|
||||
|
||||
/** Normalizes an array-backed string list, removes duplicates, and sorts it. */
|
||||
export function normalizeSortedUniqueTrimmedStringList(value: unknown): string[] {
|
||||
return sortUniqueStrings(normalizeTrimmedStringList(value));
|
||||
}
|
||||
|
||||
/** Returns undefined instead of an empty normalized array-backed string list. */
|
||||
export function normalizeOptionalTrimmedStringList(value: unknown): string[] | undefined {
|
||||
const normalized = normalizeTrimmedStringList(value);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
/** Returns undefined for non-arrays but preserves an empty array for explicit arrays. */
|
||||
export function normalizeArrayBackedTrimmedStringList(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeTrimmedStringList(value);
|
||||
}
|
||||
|
||||
/** Normalizes either a single string-like value or an array-backed string list. */
|
||||
export function normalizeSingleOrTrimmedStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeTrimmedStringList(value);
|
||||
}
|
||||
const normalized = normalizeOptionalString(value);
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
/** Normalizes single-or-array string input and removes duplicates. */
|
||||
export function normalizeUniqueSingleOrTrimmedStringList(value: unknown): string[] {
|
||||
return uniqueStrings(normalizeSingleOrTrimmedStringList(value));
|
||||
}
|
||||
|
||||
/** Parses either array entries or comma-separated string entries into trimmed values. */
|
||||
export function normalizeCsvOrLooseStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeStringEntries(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeSlugInput(raw?: string | null) {
|
||||
// NFC keeps visually identical composed/decomposed Unicode labels matching the
|
||||
// same slug while preserving non-Latin channel and room names.
|
||||
return (normalizeOptionalLowercaseString(raw) ?? "").normalize("NFC");
|
||||
}
|
||||
|
||||
/** Normalizes user-facing names into permissive lowercase slugs that may keep #/@/._+. */
|
||||
export function normalizeHyphenSlug(raw?: string | null) {
|
||||
const trimmed = normalizeSlugInput(raw);
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const dashed = trimmed.replace(/\s+/g, "-");
|
||||
const cleaned = dashed.replace(/[^\p{L}\p{M}\p{N}#@._+-]+/gu, "-");
|
||||
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||
}
|
||||
|
||||
/** Normalizes @/#-prefixed channel names into strict lowercase hyphen slugs without the prefix. */
|
||||
export function normalizeAtHashSlug(raw?: string | null) {
|
||||
const trimmed = normalizeSlugInput(raw);
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const withoutPrefix = trimmed.replace(/^[@#]+/, "");
|
||||
const dashed = withoutPrefix.replace(/[\s_]+/g, "-");
|
||||
const cleaned = dashed.replace(/[^\p{L}\p{M}\p{N}-]+/gu, "-");
|
||||
return cleaned.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
88
packages/normalization-core/src/utf16-slice.test.ts
Normal file
88
packages/normalization-core/src/utf16-slice.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Tests for surrogate-safe UTF-16 string slicing helpers.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sliceUtf16Safe, truncateUtf16Safe } from "./utf16-slice.js";
|
||||
|
||||
describe("sliceUtf16Safe", () => {
|
||||
it("slices ASCII string normally", () => {
|
||||
expect(sliceUtf16Safe("hello world", 0, 5)).toBe("hello");
|
||||
});
|
||||
|
||||
it("handles negative start", () => {
|
||||
expect(sliceUtf16Safe("hello world", -5)).toBe("world");
|
||||
});
|
||||
|
||||
it("handles negative end", () => {
|
||||
expect(sliceUtf16Safe("hello world", 0, -6)).toBe("hello");
|
||||
});
|
||||
|
||||
it("handles start beyond length", () => {
|
||||
expect(sliceUtf16Safe("hello", 10)).toBe("");
|
||||
});
|
||||
|
||||
it("handles end beyond length", () => {
|
||||
expect(sliceUtf16Safe("hello", 0, 10)).toBe("hello");
|
||||
});
|
||||
|
||||
it("swaps start and end when start > end", () => {
|
||||
expect(sliceUtf16Safe("hello", 3, 1)).toBe("el");
|
||||
});
|
||||
|
||||
it("preserves emoji with surrogate pairs", () => {
|
||||
const emoji = "👨👩👧👦";
|
||||
expect(sliceUtf16Safe(emoji, 0)).toBe(emoji);
|
||||
});
|
||||
|
||||
it("returns empty string when slicing middle of surrogate pair", () => {
|
||||
const input = "👨👩";
|
||||
// Slicing at position 1-3 hits middle of surrogate pairs
|
||||
expect(sliceUtf16Safe(input, 1, 3)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when slicing at start of surrogate pair", () => {
|
||||
const input = "👨👩";
|
||||
// Slicing at position 0-1 would cut surrogate pair, adjust to 0
|
||||
expect(sliceUtf16Safe(input, 0, 1)).toBe("");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(sliceUtf16Safe("", 0)).toBe("");
|
||||
});
|
||||
|
||||
it("handles undefined end", () => {
|
||||
expect(sliceUtf16Safe("hello", 2)).toBe("llo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateUtf16Safe", () => {
|
||||
it("returns input when shorter than limit", () => {
|
||||
expect(truncateUtf16Safe("hello", 10)).toBe("hello");
|
||||
});
|
||||
|
||||
it("truncates when longer than limit", () => {
|
||||
expect(truncateUtf16Safe("hello world", 5)).toBe("hello");
|
||||
});
|
||||
|
||||
it("handles zero limit", () => {
|
||||
expect(truncateUtf16Safe("hello", 0)).toBe("");
|
||||
});
|
||||
|
||||
it("handles negative limit", () => {
|
||||
expect(truncateUtf16Safe("hello", -1)).toBe("");
|
||||
});
|
||||
|
||||
it("floors decimal limit", () => {
|
||||
expect(truncateUtf16Safe("hello world", 5.7)).toBe("hello");
|
||||
});
|
||||
|
||||
it("preserves emoji with surrogate pairs", () => {
|
||||
const emoji = "👨👩👧👦";
|
||||
const result = truncateUtf16Safe(emoji, 10);
|
||||
// Should not return dangling surrogate
|
||||
expect(result.length).toBeLessThanOrEqual(emoji.length);
|
||||
});
|
||||
|
||||
it("returns empty string when truncating at surrogate pair boundary", () => {
|
||||
const input = "👨👩";
|
||||
expect(truncateUtf16Safe(input, 1)).toBe("");
|
||||
});
|
||||
});
|
||||
52
packages/normalization-core/src/utf16-slice.ts
Normal file
52
packages/normalization-core/src/utf16-slice.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Surrogate-safe UTF-16 string slicing helpers.
|
||||
//
|
||||
// Kept dependency-free (no node: imports) so browser/UI bundles can import them
|
||||
// without dragging in filesystem/runtime code. See utils.ts, which re-exports
|
||||
// these for the broad runtime surface.
|
||||
|
||||
function isHighSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
||||
}
|
||||
|
||||
function isLowSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
||||
}
|
||||
|
||||
/** Slices a UTF-16 string without returning dangling surrogate halves at either edge. */
|
||||
export function sliceUtf16Safe(input: string, start: number, end?: number): string {
|
||||
const len = input.length;
|
||||
|
||||
let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
|
||||
let to = end === undefined ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len);
|
||||
|
||||
if (to < from) {
|
||||
const tmp = from;
|
||||
from = to;
|
||||
to = tmp;
|
||||
}
|
||||
|
||||
if (from > 0 && from < len) {
|
||||
const codeUnit = input.charCodeAt(from);
|
||||
if (isLowSurrogate(codeUnit) && isHighSurrogate(input.charCodeAt(from - 1))) {
|
||||
from += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (to > 0 && to < len) {
|
||||
const codeUnit = input.charCodeAt(to - 1);
|
||||
if (isHighSurrogate(codeUnit) && isLowSurrogate(input.charCodeAt(to))) {
|
||||
to -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return input.slice(from, to);
|
||||
}
|
||||
|
||||
/** Truncates a UTF-16 string without cutting a surrogate pair in half. */
|
||||
export function truncateUtf16Safe(input: string, maxLen: number): string {
|
||||
const limit = Math.max(0, Math.floor(maxLen));
|
||||
if (input.length <= limit) {
|
||||
return input;
|
||||
}
|
||||
return sliceUtf16Safe(input, 0, limit);
|
||||
}
|
||||
Reference in New Issue
Block a user