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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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"
}
}

View 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();
},
);
});

View 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;
}

View 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;
}

View 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";

View 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));
});

View 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;
}
}

View 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);
});
});

View 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);
}

View 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");
});
});

View 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;
}

View 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([]);
});
});

View 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;
}

View 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);
});
});

View 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, "");
}

View 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("");
});
});

View 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);
}