From 2721a2870526920fc8d026bbb0afc1a924b30e20 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sat, 13 Jun 2026 21:44:04 +0000 Subject: [PATCH 01/34] =?UTF-8?q?feat(CI-CD):=20=E2=9C=A8=20read=20type=20?= =?UTF-8?q?definitions=20from=20source=20files=20for=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build/build-website-metadata.ts | 46 +++++++++++++++++-------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/scripts/build/build-website-metadata.ts b/scripts/build/build-website-metadata.ts index f35f0310..288ea87c 100644 --- a/scripts/build/build-website-metadata.ts +++ b/scripts/build/build-website-metadata.ts @@ -5,6 +5,7 @@ */ import { readdir } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { ensureDir } from 'fs-extra'; import { @@ -237,22 +238,39 @@ function processMember(child: DeclarationReflection): WebsiteFunction | undefine const since = extractTagText(comment?.blockTags as CommentTag[] | undefined, '@since') ?? 'unknown'; const examples = extractExamples(comment?.blockTags as CommentTag[] | undefined); - // For type aliases: build the `type Name = ...` definition string + // For type aliases: read the definition verbatim from the source file so that + // complex constructs (conditional types, mapped types, etc.) render correctly. // For interfaces: build the `interface Name { ... }` definition string let typeDefinition: string | undefined; - if (kind === 'type' && (child as unknown as Record).type) { - const rawType = (child as unknown as Record).type; - const typeStr = serializeType(rawType); - const typeParams = child.typeParameters - ?.map((tp: TypeParameterReflection) => { - let s = tp.name; - if (tp.type) s += ` extends ${serializeType(tp.type)}`; - if (tp.default) s += ` = ${serializeType(tp.default)}`; - return s; - }) - .join(', '); - const generics = typeParams ? `<${typeParams}>` : ''; - typeDefinition = `type ${child.name}${generics} = ${typeStr}`; + if (kind === 'type') { + const srcRef = child.sources?.[0] as ({ fullFileName?: string } & object) | undefined; + const srcPath = srcRef?.fullFileName; + if (srcPath) { + try { + const src = readFileSync(srcPath, 'utf-8'); + // Extract from `export type NAME` to end of file, strip the `export ` prefix + const match = src.match(/export\s+(type\s+\S[\s\S]*)$/); + if (match) { + typeDefinition = match[1].trimEnd().replace(/;$/, ''); + } + } catch { + // fallback to serialized form + } + } + if (!typeDefinition && (child as unknown as Record).type) { + const rawType = (child as unknown as Record).type; + const typeStr = serializeType(rawType); + const typeParams = child.typeParameters + ?.map((tp: TypeParameterReflection) => { + let s = tp.name; + if (tp.type) s += ` extends ${serializeType(tp.type)}`; + if (tp.default) s += ` = ${serializeType(tp.default)}`; + return s; + }) + .join(', '); + const generics = typeParams ? `<${typeParams}>` : ''; + typeDefinition = `type ${child.name}${generics} = ${typeStr}`; + } } else if (kind === 'interface') { const members = ((child as unknown as Record).children as DeclarationReflection[] | undefined) ?? []; const memberDefinitions = members.flatMap(m => { From 070ad7a4a55653408ddffd47c1033863ad79149d Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 10:40:17 +0000 Subject: [PATCH 02/34] =?UTF-8?q?refactor(array):=20=E2=99=BB=EF=B8=8F=20m?= =?UTF-8?q?ark=20DEFAULT=5FSORT=5FSTRING=5FPROPS=20as=20internal=20-=20cha?= =?UTF-8?q?nge=20@since=20tag=20to=20@internal=20refactor(sortNatural):=20?= =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20natVal=20function=20to=20internal?= =?UTF-8?q?=20-=20add=20double-safety=20check=20for=20@since=20tag=20in=20?= =?UTF-8?q?processMember?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/array/sort.ts | 2 +- helpers/array/sortNatural.ts | 9 +++++---- scripts/build/build-website-metadata.ts | 5 +++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/helpers/array/sort.ts b/helpers/array/sort.ts index cd83fce7..c8c2d089 100644 --- a/helpers/array/sort.ts +++ b/helpers/array/sort.ts @@ -13,7 +13,7 @@ export type SortFn = (a: T, b: T) => number; /** * Default property names checked (in order) by auto-detecting sort helpers * when no explicit property key is provided. - * @since 2.0.2 + * @internal */ export const DEFAULT_SORT_STRING_PROPS = ['value', 'label', 'title', 'description'] as const; diff --git a/helpers/array/sortNatural.ts b/helpers/array/sortNatural.ts index 5a4cf2b2..c40e0d54 100644 --- a/helpers/array/sortNatural.ts +++ b/helpers/array/sortNatural.ts @@ -61,6 +61,11 @@ export const sortStringNaturalAscInsensitiveFn: SortFn = (a: string, b: export const sortStringNaturalDescInsensitiveFn: SortFn = (a: string, b: string) => getNaturalCollatorInsensitive().compare(b, a); +/** @internal */ +function natVal>(obj: T, key: keyof T): string { + return String(obj[key] ?? ''); +} + /** * Creates a sort function for objects by one or more string properties using * natural ordering. Numbers embedded in values are compared numerically: @@ -75,10 +80,6 @@ export const sortStringNaturalDescInsensitiveFn: SortFn = (a: string, b: * @returns Sort function * @since 2.0.2 */ -function natVal>(obj: T, key: keyof T): string { - return String(obj[key] ?? ''); -} - export function createSortByNaturalFn>( property?: keyof T | readonly (keyof T)[], caseInsensitive: boolean = false, diff --git a/scripts/build/build-website-metadata.ts b/scripts/build/build-website-metadata.ts index 288ea87c..55b3f6e2 100644 --- a/scripts/build/build-website-metadata.ts +++ b/scripts/build/build-website-metadata.ts @@ -236,6 +236,11 @@ function processMember(child: DeclarationReflection): WebsiteFunction | undefine const description = extractText(comment?.summary as Array<{ kind: string; text: string }> | undefined); const since = extractTagText(comment?.blockTags as CommentTag[] | undefined, '@since') ?? 'unknown'; + + // Double-safety: exclude anything without an explicit @since tag. + // Primary guard is @internal + excludeInternal:true in TypeDoc options. + if (since === 'unknown') return undefined; + const examples = extractExamples(comment?.blockTags as CommentTag[] | undefined); // For type aliases: read the definition verbatim from the source file so that From eb3bca534e3f0977483d27f04ce54700155a4fbb Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 10:44:56 +0000 Subject: [PATCH 03/34] =?UTF-8?q?feat(CI-C=20CD):=20=E2=9C=A8=20add=20runt?= =?UTF-8?q?imes=20field=20for=20consumer=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - define runtimes for node and browser in package.json - update createBundleMetadata to read from runtimes field - enhance type extraction logic in buildWebsiteMetadata --- package.json | 4 ++++ scripts/build/build-website-metadata.ts | 13 ++++++++----- scripts/build/helpers/create-bundle-metadata.ts | 10 +++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index dd6101aa..d6f7c3b9 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,10 @@ "node": ">=24.0.0", "browser": "ES2022+" }, + "runtimes": { + "node": ">=20.0.0", + "browser": "ES2022+" + }, "pnpm": { "overrides": { "postcss": ">=8.5.10", diff --git a/scripts/build/build-website-metadata.ts b/scripts/build/build-website-metadata.ts index 55b3f6e2..5ac619ab 100644 --- a/scripts/build/build-website-metadata.ts +++ b/scripts/build/build-website-metadata.ts @@ -252,11 +252,14 @@ function processMember(child: DeclarationReflection): WebsiteFunction | undefine const srcPath = srcRef?.fullFileName; if (srcPath) { try { - const src = readFileSync(srcPath, 'utf-8'); - // Extract from `export type NAME` to end of file, strip the `export ` prefix - const match = src.match(/export\s+(type\s+\S[\s\S]*)$/); - if (match) { - typeDefinition = match[1].trimEnd().replace(/;$/, ''); + const src = readSourceCached(srcPath); + // Find the specific declaration: `export type NAME` → extract from `type NAME` + // up to the closing `;` at brace-depth 0 (handles multi-line conditional types). + const exportStart = src.indexOf(`export type ${child.name}`); + if (exportStart !== -1) { + const typeStart = exportStart + 'export '.length; + const end = findTopLevelSemicolon(src, typeStart); + typeDefinition = src.slice(typeStart, end).trimEnd(); } } catch { // fallback to serialized form diff --git a/scripts/build/helpers/create-bundle-metadata.ts b/scripts/build/helpers/create-bundle-metadata.ts index f69d9c39..f826fe44 100644 --- a/scripts/build/helpers/create-bundle-metadata.ts +++ b/scripts/build/helpers/create-bundle-metadata.ts @@ -57,14 +57,14 @@ export async function createBundleMetadata( return `https://dashboard.stryker-mutator.io/reports/github.com/${parsed.slug}/v${version}`; })(); - // Runtime compatibility — read from package.json engines field. - // Deno and Bun are structurally compatible (ESM-only, no native addons) with no per-version constraints. - const engines = (rootPackage.engines as Record | undefined) ?? {}; + // Consumer runtime compatibility — read from package.json "runtimes" field (not "engines", + // which tracks the dev/build tooling requirement and may be higher than the consumer minimum). + const runtimesField = (rootPackage.runtimes as Record | undefined) ?? {}; const runtimes = { - node: engines.node ?? '>=24.0.0', + node: runtimesField.node ?? '>=20.0.0', deno: 'compatible', bun: 'compatible', - browser: engines.browser ?? 'ES2022+', + browser: runtimesField.browser ?? 'ES2022+', }; // Create build.json with build metadata From ba8e6ea28ce5875af9f0133e6e907b3a4ce7527f Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:00:45 +0000 Subject: [PATCH 04/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isInfinit?= =?UTF-8?q?e=20example=20and=20notes=20to=20native=20alternatives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/native-alternatives.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/native-alternatives.json b/docs/native-alternatives.json index b2cf6b5f..38c24506 100644 --- a/docs/native-alternatives.json +++ b/docs/native-alternatives.json @@ -317,6 +317,16 @@ "example": "Number.isFinite(value)", "notes": "Prefer Number.isFinite() over global isFinite() which coerces" }, + { + "name": "isInfinite", + "libraries": [ + "sindresorhus/is" + ], + "native": "value === Infinity || value === -Infinity / !Number.isFinite(value) && !Number.isNaN(value)", + "since": "ES2015", + "example": "value === Infinity || value === -Infinity\n// or: !Number.isFinite(value) && !Number.isNaN(value)", + "notes": "Number.isFinite() is the complement; compose with a NaN guard if needed. No dedicated helper warranted." + }, { "name": "isSet (Set data structure)", "libraries": [ From bbc458b0c1e5cb29bf437e1d4ca6ea5da0b5dded Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:00:57 +0000 Subject: [PATCH 05/34] =?UTF-8?q?feat(node):=20=E2=9C=A8=20add=20isNodeStr?= =?UTF-8?q?eam=20function=20and=20examples=20-=20implement=20isNodeStream?= =?UTF-8?q?=20to=20check=20for=20Node.js=20streams=20-=20add=20property-ba?= =?UTF-8?q?sed=20tests=20for=20isNodeStream=20-=20create=20example=20usage?= =?UTF-8?q?=20for=20isNodeStream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/node/isNodeStream.example.ts | 46 +++++++++++++++++++++++++ helpers/node/isNodeStream.spec.ts | 30 ++++++++++++++++ helpers/node/isNodeStream.test.ts | 51 ++++++++++++++++++++++++++++ helpers/node/isNodeStream.ts | 31 +++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 helpers/node/isNodeStream.example.ts create mode 100644 helpers/node/isNodeStream.spec.ts create mode 100644 helpers/node/isNodeStream.test.ts create mode 100644 helpers/node/isNodeStream.ts diff --git a/helpers/node/isNodeStream.example.ts b/helpers/node/isNodeStream.example.ts new file mode 100644 index 00000000..e25b6808 --- /dev/null +++ b/helpers/node/isNodeStream.example.ts @@ -0,0 +1,46 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { Readable, Writable } from 'node:stream'; +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNodeStream } from './isNodeStream'; + +const examples: HelperExamples = { + helper: 'isNodeStream', + category: 'node', + examples: [ + { + title: 'Detect a Node.js stream', + description: 'Returns true for any object with a .pipe() method (Readable, Writable, Transform, etc.).', + code: `import { Readable } from 'node:stream'; +isNodeStream(new Readable({ read() {} })) // => true +isNodeStream({}) // => false +isNodeStream(null) // => false`, + assert: () => { + if (!isNodeStream(new Readable({ read() {} }))) throw new Error('Readable should be a stream'); + if (!isNodeStream(new Writable({ write() {} }))) throw new Error('Writable should be a stream'); + if (isNodeStream({})) throw new Error('{} should not be a stream'); + if (isNodeStream(null)) throw new Error('null should not be a stream'); + }, + }, + { + title: 'Guard before piping an unknown value', + description: 'Use isNodeStream to safely pipe only known streams.', + code: `import { Writable } from 'node:stream'; +function pipeToOutput(source: unknown, dest: Writable): void { + if (isNodeStream(source)) { + source.pipe(dest); + } +}`, + assert: () => { + if (!isNodeStream({ pipe: () => {} })) throw new Error('duck-type pipe fn should return true'); + if (isNodeStream({ pipe: 'not-a-function' })) throw new Error('non-fn pipe should return false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/node/isNodeStream.spec.ts b/helpers/node/isNodeStream.spec.ts new file mode 100644 index 00000000..2a132323 --- /dev/null +++ b/helpers/node/isNodeStream.spec.ts @@ -0,0 +1,30 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { Readable, Writable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { isNodeStream } from './isNodeStream'; + +describe('isNodeStream — property-based', () => { + it('primitives are never streams', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isNodeStream(value)).toBe(false); + }), + ); + }); +}); + +describe('isNodeStream — contract', () => { + it('null → false', () => expect(isNodeStream(null)).toBe(false)); + it('undefined → false', () => expect(isNodeStream(undefined)).toBe(false)); + it('{} → false', () => expect(isNodeStream({})).toBe(false)); + it('{ pipe: non-fn } → false', () => expect(isNodeStream({ pipe: 42 })).toBe(false)); + it('{ pipe: fn } → true', () => expect(isNodeStream({ pipe: () => {} })).toBe(true)); + it('Readable → true', () => expect(isNodeStream(new Readable({ read() {} }))).toBe(true)); + it('Writable → true', () => expect(isNodeStream(new Writable({ write() {} }))).toBe(true)); +}); diff --git a/helpers/node/isNodeStream.test.ts b/helpers/node/isNodeStream.test.ts new file mode 100644 index 00000000..11f6aa55 --- /dev/null +++ b/helpers/node/isNodeStream.test.ts @@ -0,0 +1,51 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { Duplex, PassThrough, Readable, Transform, Writable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { isNodeStream } from './isNodeStream'; + +describe('isNodeStream', () => { + it('should return true for Readable streams', () => { + expect(isNodeStream(new Readable({ read() {} }))).toBe(true); + }); + + it('should return true for Writable streams', () => { + expect(isNodeStream(new Writable({ write() {} }))).toBe(true); + }); + + it('should return true for Duplex and Transform streams', () => { + expect(isNodeStream(new Duplex())).toBe(true); + expect(isNodeStream(new Transform())).toBe(true); + expect(isNodeStream(new PassThrough())).toBe(true); + }); + + it('should return true for any object with a pipe function', () => { + expect(isNodeStream({ pipe: () => {} })).toBe(true); + }); + + it('should return false when pipe is not a function', () => { + expect(isNodeStream({ pipe: 'not-a-function' })).toBe(false); + expect(isNodeStream({ pipe: null })).toBe(false); + expect(isNodeStream({ pipe: 42 })).toBe(false); + }); + + it('should return false for objects without pipe', () => { + expect(isNodeStream({})).toBe(false); + expect(isNodeStream({ read: () => {} })).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isNodeStream(null)).toBe(false); + expect(isNodeStream(undefined)).toBe(false); + }); + + it('should return false for primitives', () => { + expect(isNodeStream(42)).toBe(false); + expect(isNodeStream('stream')).toBe(false); + expect(isNodeStream(true)).toBe(false); + }); +}); diff --git a/helpers/node/isNodeStream.ts b/helpers/node/isNodeStream.ts new file mode 100644 index 00000000..e8a8cc08 --- /dev/null +++ b/helpers/node/isNodeStream.ts @@ -0,0 +1,31 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a Node.js stream (has a `.pipe()` method). + * + * Uses duck-typing: any object with a `pipe` function qualifies, covering + * `Readable`, `Writable`, `Duplex`, `Transform`, and custom stream-compatible + * objects without importing from `node:stream`. + * + * @param value - The value to check + * @returns `true` if value is a Node.js stream + * @example + * import { Readable } from 'node:stream'; + * isNodeStream(new Readable()) // => true + * isNodeStream({}) // => false + * isNodeStream(null) // => false + * @since next + */ +export function isNodeStream(value: unknown): value is { pipe: (...args: unknown[]) => unknown } { + return ( + // Intentionally `object`-only, unlike `isPromiseLike` (see type/isPromiseLike.ts): + // a callable function exposing `.pipe()` would be an unusual, contrived stream shape. + value !== null && + typeof value === 'object' && + typeof (value as Record)['pipe'] === 'function' + ); +} From 1ae5796ef38eeefb975aa9434ffc4b3181ac5107 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:01:07 +0000 Subject: [PATCH 06/34] =?UTF-8?q?feat(node):=20=E2=9C=A8=20add=20isSharedA?= =?UTF-8?q?rrayBuffer=20function=20and=20examples=20-=20implement=20isShar?= =?UTF-8?q?edArrayBuffer=20to=20check=20for=20SharedArrayBuffer=20instance?= =?UTF-8?q?s=20-=20add=20examples=20for=20usage=20and=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/node/isSharedArrayBuffer.example.ts | 45 +++++++++++++++++++++ helpers/node/isSharedArrayBuffer.spec.ts | 29 +++++++++++++ helpers/node/isSharedArrayBuffer.test.ts | 37 +++++++++++++++++ helpers/node/isSharedArrayBuffer.ts | 24 +++++++++++ 4 files changed, 135 insertions(+) create mode 100644 helpers/node/isSharedArrayBuffer.example.ts create mode 100644 helpers/node/isSharedArrayBuffer.spec.ts create mode 100644 helpers/node/isSharedArrayBuffer.test.ts create mode 100644 helpers/node/isSharedArrayBuffer.ts diff --git a/helpers/node/isSharedArrayBuffer.example.ts b/helpers/node/isSharedArrayBuffer.example.ts new file mode 100644 index 00000000..0b08cac1 --- /dev/null +++ b/helpers/node/isSharedArrayBuffer.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isSharedArrayBuffer } from './isSharedArrayBuffer'; + +const examples: HelperExamples = { + helper: 'isSharedArrayBuffer', + category: 'node', + examples: [ + { + title: 'Distinguish SharedArrayBuffer from ArrayBuffer', + description: 'Returns true only for SharedArrayBuffer instances, not plain ArrayBuffers.', + code: `isSharedArrayBuffer(new SharedArrayBuffer(8)) // => true +isSharedArrayBuffer(new ArrayBuffer(8)) // => false +isSharedArrayBuffer(null) // => false`, + assert: () => { + if (!isSharedArrayBuffer(new SharedArrayBuffer(8))) throw new Error('SharedArrayBuffer should return true'); + if (isSharedArrayBuffer(new ArrayBuffer(8))) throw new Error('ArrayBuffer should return false'); + if (isSharedArrayBuffer(null)) throw new Error('null should return false'); + }, + }, + { + title: 'Safe shared memory check before worker communication', + description: 'Use as a guard to ensure a buffer can be transferred to a Worker.', + code: `function sendToWorker(buffer: unknown): void { + if (isSharedArrayBuffer(buffer)) { + // buffer is SharedArrayBuffer — can be shared directly + // worker.postMessage({ buffer }); + } else { + // must transfer or copy + } +}`, + assert: () => { + if (!isSharedArrayBuffer(new SharedArrayBuffer(0))) throw new Error('0-length SAB should return true'); + if (isSharedArrayBuffer(new Uint8Array(8).buffer)) throw new Error('ArrayBuffer view should return false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/node/isSharedArrayBuffer.spec.ts b/helpers/node/isSharedArrayBuffer.spec.ts new file mode 100644 index 00000000..4e8f28be --- /dev/null +++ b/helpers/node/isSharedArrayBuffer.spec.ts @@ -0,0 +1,29 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isSharedArrayBuffer } from './isSharedArrayBuffer'; + +describe('isSharedArrayBuffer — property-based', () => { + it('primitives are never SharedArrayBuffers', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isSharedArrayBuffer(value)).toBe(false); + }), + ); + }); +}); + +describe('isSharedArrayBuffer — contract', () => { + it('null → false', () => expect(isSharedArrayBuffer(null)).toBe(false)); + it('undefined → false', () => expect(isSharedArrayBuffer(undefined)).toBe(false)); + it('ArrayBuffer → false', () => expect(isSharedArrayBuffer(new ArrayBuffer(8))).toBe(false)); + it('Uint8Array → false', () => expect(isSharedArrayBuffer(new Uint8Array(8))).toBe(false)); + it('{} → false', () => expect(isSharedArrayBuffer({})).toBe(false)); + it('SharedArrayBuffer(0) → true', () => expect(isSharedArrayBuffer(new SharedArrayBuffer(0))).toBe(true)); + it('SharedArrayBuffer(16) → true', () => expect(isSharedArrayBuffer(new SharedArrayBuffer(16))).toBe(true)); +}); diff --git a/helpers/node/isSharedArrayBuffer.test.ts b/helpers/node/isSharedArrayBuffer.test.ts new file mode 100644 index 00000000..ae209507 --- /dev/null +++ b/helpers/node/isSharedArrayBuffer.test.ts @@ -0,0 +1,37 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isSharedArrayBuffer } from './isSharedArrayBuffer'; + +describe('isSharedArrayBuffer', () => { + it('should return true for SharedArrayBuffer instances', () => { + expect(isSharedArrayBuffer(new SharedArrayBuffer(8))).toBe(true); + expect(isSharedArrayBuffer(new SharedArrayBuffer(0))).toBe(true); + }); + + it('should return false for regular ArrayBuffer', () => { + expect(isSharedArrayBuffer(new ArrayBuffer(8))).toBe(false); + expect(isSharedArrayBuffer(new ArrayBuffer(0))).toBe(false); + }); + + it('should return false for typed arrays', () => { + expect(isSharedArrayBuffer(new Uint8Array(8))).toBe(false); + expect(isSharedArrayBuffer(new Int32Array(4))).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isSharedArrayBuffer(null)).toBe(false); + expect(isSharedArrayBuffer(undefined)).toBe(false); + }); + + it('should return false for other values', () => { + expect(isSharedArrayBuffer({})).toBe(false); + expect(isSharedArrayBuffer([])).toBe(false); + expect(isSharedArrayBuffer(42)).toBe(false); + expect(isSharedArrayBuffer('sab')).toBe(false); + }); +}); diff --git a/helpers/node/isSharedArrayBuffer.ts b/helpers/node/isSharedArrayBuffer.ts new file mode 100644 index 00000000..70d5675d --- /dev/null +++ b/helpers/node/isSharedArrayBuffer.ts @@ -0,0 +1,24 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a `SharedArrayBuffer` instance. + * + * `SharedArrayBuffer` enables shared memory between the main thread and worker + * threads. In browsers without COOP/COEP headers, `SharedArrayBuffer` may be + * unavailable; this function returns `false` in that case. + * + * @param value - The value to check + * @returns `true` if value is a SharedArrayBuffer + * @example + * isSharedArrayBuffer(new SharedArrayBuffer(8)) // => true + * isSharedArrayBuffer(new ArrayBuffer(8)) // => false + * isSharedArrayBuffer(null) // => false + * @since next + */ +export function isSharedArrayBuffer(value: unknown): value is SharedArrayBuffer { + return typeof SharedArrayBuffer !== 'undefined' && value instanceof SharedArrayBuffer; +} From 704bb1bedd77ad3478bd3239d5517d6e86104982 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:01:16 +0000 Subject: [PATCH 07/34] =?UTF-8?q?feat(number):=20=E2=9C=A8=20add=20isEven?= =?UTF-8?q?=20function=20with=20examples=20and=20tests=20-=20implement=20i?= =?UTF-8?q?sEven=20function=20to=20check=20for=20even=20integers=20-=20add?= =?UTF-8?q?=20examples=20for=20usage=20of=20isEven=20-=20create=20property?= =?UTF-8?q?-based=20and=20contract=20tests=20for=20isEven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/number/isEven.example.ts | 43 +++++++++++++++++++++++++++++ helpers/number/isEven.spec.ts | 45 +++++++++++++++++++++++++++++++ helpers/number/isEven.test.ts | 46 ++++++++++++++++++++++++++++++++ helpers/number/isEven.ts | 25 +++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 helpers/number/isEven.example.ts create mode 100644 helpers/number/isEven.spec.ts create mode 100644 helpers/number/isEven.test.ts create mode 100644 helpers/number/isEven.ts diff --git a/helpers/number/isEven.example.ts b/helpers/number/isEven.example.ts new file mode 100644 index 00000000..f612d909 --- /dev/null +++ b/helpers/number/isEven.example.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isEven } from './isEven'; + +const examples: HelperExamples = { + helper: 'isEven', + category: 'number', + examples: [ + { + title: 'Check if a number is even', + description: 'Returns true for integers divisible by 2, false otherwise.', + code: `isEven(4) // => true +isEven(0) // => true +isEven(3) // => false +isEven(1.5) // => false (not an integer)`, + assert: () => { + if (!isEven(4)) throw new Error('4 should be even'); + if (!isEven(0)) throw new Error('0 should be even'); + if (isEven(3)) throw new Error('3 should not be even'); + if (isEven(1.5)) throw new Error('1.5 should not be even'); + }, + }, + { + title: 'Filter even numbers from an array', + description: 'Use as a predicate in .filter() to extract even integers.', + code: `const nums = [1, 2, 3, 4, 5, 6]; +nums.filter(isEven) +// => [2, 4, 6]`, + assert: () => { + const nums = [1, 2, 3, 4, 5, 6]; + const result = nums.filter(isEven); + if (result.length !== 3 || result[0] !== 2) throw new Error('Expected [2, 4, 6]'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/number/isEven.spec.ts b/helpers/number/isEven.spec.ts new file mode 100644 index 00000000..40d7a986 --- /dev/null +++ b/helpers/number/isEven.spec.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isEven } from './isEven'; + +describe('isEven — property-based', () => { + it('n*2 is always even for any integer n', () => { + fc.assert( + fc.property(fc.integer({ min: -1_000_000, max: 1_000_000 }), (n) => { + expect(isEven(n * 2)).toBe(true); + }), + ); + }); + + it('n*2+1 is never even for any integer n', () => { + fc.assert( + fc.property(fc.integer({ min: -1_000_000, max: 1_000_000 }), (n) => { + expect(isEven(n * 2 + 1)).toBe(false); + }), + ); + }); + + it('primitives that are not number always return false', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.boolean()), (value) => { + expect(isEven(value)).toBe(false); + }), + ); + }); +}); + +describe('isEven — contract', () => { + it('0 is even', () => expect(isEven(0)).toBe(true)); + it('-0 is even', () => expect(isEven(-0)).toBe(true)); + it('NaN → false', () => expect(isEven(NaN)).toBe(false)); + it('Infinity → false', () => expect(isEven(Infinity)).toBe(false)); + it('-Infinity → false', () => expect(isEven(-Infinity)).toBe(false)); + it('1.5 → false (non-integer)', () => expect(isEven(1.5)).toBe(false)); + it('2.0 → true (integer stored as float)', () => expect(isEven(2.0)).toBe(true)); +}); diff --git a/helpers/number/isEven.test.ts b/helpers/number/isEven.test.ts new file mode 100644 index 00000000..1e085c8d --- /dev/null +++ b/helpers/number/isEven.test.ts @@ -0,0 +1,46 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isEven } from './isEven'; + +describe('isEven', () => { + it('should return true for even integers', () => { + expect(isEven(0)).toBe(true); + expect(isEven(2)).toBe(true); + expect(isEven(4)).toBe(true); + expect(isEven(-2)).toBe(true); + expect(isEven(-100)).toBe(true); + }); + + it('should return false for odd integers', () => { + expect(isEven(1)).toBe(false); + expect(isEven(3)).toBe(false); + expect(isEven(-1)).toBe(false); + expect(isEven(-7)).toBe(false); + }); + + it('should return false for non-integer numbers', () => { + expect(isEven(1.5)).toBe(false); + expect(isEven(2.4)).toBe(false); + expect(isEven(-0.5)).toBe(false); + }); + + it('should return false for NaN and Infinity', () => { + expect(isEven(NaN)).toBe(false); + expect(isEven(Infinity)).toBe(false); + expect(isEven(-Infinity)).toBe(false); + }); + + it('should return false for non-number types', () => { + expect(isEven('2')).toBe(false); + expect(isEven(null)).toBe(false); + expect(isEven(undefined)).toBe(false); + expect(isEven(true)).toBe(false); + expect(isEven({})).toBe(false); + expect(isEven([])).toBe(false); + }); +}); diff --git a/helpers/number/isEven.ts b/helpers/number/isEven.ts new file mode 100644 index 00000000..1e6fdd1f --- /dev/null +++ b/helpers/number/isEven.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is an even integer. + * + * Returns `false` for non-numbers, non-integers, `NaN`, `Infinity`, and odd integers. + * + * @param value - The value to check + * @returns `true` if value is an integer divisible by 2 + * @example + * isEven(2) // => true + * isEven(0) // => true + * isEven(-4) // => true + * isEven(3) // => false + * isEven(1.5) // => false (not an integer) + * isEven('2') // => false + * @since next + */ +export function isEven(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value % 2 === 0; +} From 6e887924cb7afd80914ceb80c6c8cf96997a10e6 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:01:24 +0000 Subject: [PATCH 08/34] =?UTF-8?q?feat(number):=20=E2=9C=A8=20add=20isOdd?= =?UTF-8?q?=20function=20with=20examples=20and=20tests=20-=20implement=20i?= =?UTF-8?q?sOdd=20function=20to=20check=20for=20odd=20integers=20-=20add?= =?UTF-8?q?=20examples=20for=20usage=20of=20isOdd=20-=20create=20property-?= =?UTF-8?q?based=20and=20contract=20tests=20for=20isOdd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/number/isOdd.example.ts | 44 +++++++++++++++++++++++++++++++ helpers/number/isOdd.spec.ts | 45 ++++++++++++++++++++++++++++++++ helpers/number/isOdd.test.ts | 46 +++++++++++++++++++++++++++++++++ helpers/number/isOdd.ts | 25 ++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 helpers/number/isOdd.example.ts create mode 100644 helpers/number/isOdd.spec.ts create mode 100644 helpers/number/isOdd.test.ts create mode 100644 helpers/number/isOdd.ts diff --git a/helpers/number/isOdd.example.ts b/helpers/number/isOdd.example.ts new file mode 100644 index 00000000..c4edb260 --- /dev/null +++ b/helpers/number/isOdd.example.ts @@ -0,0 +1,44 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isOdd } from './isOdd'; + +const examples: HelperExamples = { + helper: 'isOdd', + category: 'number', + examples: [ + { + title: 'Check if a number is odd', + description: 'Returns true for integers not divisible by 2, false otherwise.', + code: `isOdd(3) // => true +isOdd(1) // => true +isOdd(2) // => false +isOdd(0) // => false +isOdd(1.5) // => false (not an integer)`, + assert: () => { + if (!isOdd(3)) throw new Error('3 should be odd'); + if (!isOdd(1)) throw new Error('1 should be odd'); + if (isOdd(2)) throw new Error('2 should not be odd'); + if (isOdd(1.5)) throw new Error('1.5 should not be odd'); + }, + }, + { + title: 'Filter odd numbers from an array', + description: 'Use as a predicate in .filter() to extract odd integers.', + code: `const nums = [1, 2, 3, 4, 5, 6]; +nums.filter(isOdd) +// => [1, 3, 5]`, + assert: () => { + const nums = [1, 2, 3, 4, 5, 6]; + const result = nums.filter(isOdd); + if (result.length !== 3 || result[0] !== 1) throw new Error('Expected [1, 3, 5]'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/number/isOdd.spec.ts b/helpers/number/isOdd.spec.ts new file mode 100644 index 00000000..f9dce9bc --- /dev/null +++ b/helpers/number/isOdd.spec.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isOdd } from './isOdd'; + +describe('isOdd — property-based', () => { + it('n*2+1 is always odd for any integer n', () => { + fc.assert( + fc.property(fc.integer({ min: -1_000_000, max: 1_000_000 }), (n) => { + expect(isOdd(n * 2 + 1)).toBe(true); + }), + ); + }); + + it('n*2 is never odd for any integer n', () => { + fc.assert( + fc.property(fc.integer({ min: -1_000_000, max: 1_000_000 }), (n) => { + expect(isOdd(n * 2)).toBe(false); + }), + ); + }); + + it('primitives that are not number always return false', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.boolean()), (value) => { + expect(isOdd(value)).toBe(false); + }), + ); + }); +}); + +describe('isOdd — contract', () => { + it('0 is not odd', () => expect(isOdd(0)).toBe(false)); + it('-0 is not odd', () => expect(isOdd(-0)).toBe(false)); + it('NaN → false', () => expect(isOdd(NaN)).toBe(false)); + it('Infinity → false', () => expect(isOdd(Infinity)).toBe(false)); + it('-Infinity → false', () => expect(isOdd(-Infinity)).toBe(false)); + it('1.5 → false (non-integer)', () => expect(isOdd(1.5)).toBe(false)); + it('3.0 → true (integer stored as float)', () => expect(isOdd(3.0)).toBe(true)); +}); diff --git a/helpers/number/isOdd.test.ts b/helpers/number/isOdd.test.ts new file mode 100644 index 00000000..e091fdad --- /dev/null +++ b/helpers/number/isOdd.test.ts @@ -0,0 +1,46 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isOdd } from './isOdd'; + +describe('isOdd', () => { + it('should return true for odd integers', () => { + expect(isOdd(1)).toBe(true); + expect(isOdd(3)).toBe(true); + expect(isOdd(-1)).toBe(true); + expect(isOdd(-7)).toBe(true); + expect(isOdd(99)).toBe(true); + }); + + it('should return false for even integers', () => { + expect(isOdd(0)).toBe(false); + expect(isOdd(2)).toBe(false); + expect(isOdd(-2)).toBe(false); + expect(isOdd(100)).toBe(false); + }); + + it('should return false for non-integer numbers', () => { + expect(isOdd(1.5)).toBe(false); + expect(isOdd(3.1)).toBe(false); + expect(isOdd(-0.5)).toBe(false); + }); + + it('should return false for NaN and Infinity', () => { + expect(isOdd(NaN)).toBe(false); + expect(isOdd(Infinity)).toBe(false); + expect(isOdd(-Infinity)).toBe(false); + }); + + it('should return false for non-number types', () => { + expect(isOdd('3')).toBe(false); + expect(isOdd(null)).toBe(false); + expect(isOdd(undefined)).toBe(false); + expect(isOdd(true)).toBe(false); + expect(isOdd({})).toBe(false); + expect(isOdd([])).toBe(false); + }); +}); diff --git a/helpers/number/isOdd.ts b/helpers/number/isOdd.ts new file mode 100644 index 00000000..b9034623 --- /dev/null +++ b/helpers/number/isOdd.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is an odd integer. + * + * Returns `false` for non-numbers, non-integers, `NaN`, `Infinity`, and even integers. + * + * @param value - The value to check + * @returns `true` if value is an integer not divisible by 2 + * @example + * isOdd(3) // => true + * isOdd(1) // => true + * isOdd(-7) // => true + * isOdd(2) // => false + * isOdd(1.5) // => false (not an integer) + * isOdd('3') // => false + * @since next + */ +export function isOdd(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value % 2 !== 0; +} From 406d9ae5ddad04f9a3e7636e2c9d4a9042fab88b Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:01:32 +0000 Subject: [PATCH 09/34] =?UTF-8?q?feat(observable):=20=E2=9C=A8=20add=20isO?= =?UTF-8?q?bservable=20function=20and=20examples=20-=20implement=20isObser?= =?UTF-8?q?vable=20to=20check=20RxJS=20observables=20-=20add=20examples=20?= =?UTF-8?q?for=20usage=20in=20isObservable.example.ts=20-=20create=20tests?= =?UTF-8?q?=20for=20isObservable=20in=20isObservable.spec.ts=20and=20isObs?= =?UTF-8?q?ervable.test.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/observable/isObservable.example.ts | 45 ++++++++++++++++++ helpers/observable/isObservable.spec.ts | 33 +++++++++++++ helpers/observable/isObservable.test.ts | 54 ++++++++++++++++++++++ helpers/observable/isObservable.ts | 36 +++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 helpers/observable/isObservable.example.ts create mode 100644 helpers/observable/isObservable.spec.ts create mode 100644 helpers/observable/isObservable.test.ts create mode 100644 helpers/observable/isObservable.ts diff --git a/helpers/observable/isObservable.example.ts b/helpers/observable/isObservable.example.ts new file mode 100644 index 00000000..31742910 --- /dev/null +++ b/helpers/observable/isObservable.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { Observable, Subject } from 'rxjs'; +import type { HelperExamples } from '../../scripts/examples/types'; +import { isObservable } from './isObservable'; + +const examples: HelperExamples = { + helper: 'isObservable', + category: 'observable', + examples: [ + { + title: 'Detect an RxJS Observable or Subject', + description: 'Returns true for Observable, Subject, BehaviorSubject, and any duck-typed observable.', + code: `import { Observable, Subject } from 'rxjs'; +isObservable(new Observable()) // => true +isObservable(new Subject()) // => true +isObservable(Promise.resolve()) // => false +isObservable({}) // => false`, + assert: () => { + if (!isObservable(new Observable())) throw new Error('Observable should return true'); + if (!isObservable(new Subject())) throw new Error('Subject should return true'); + if (isObservable(Promise.resolve())) throw new Error('Promise should return false'); + if (isObservable({})) throw new Error('{} should return false'); + }, + }, + { + title: 'Accept either an Observable or a plain value', + description: 'Use as a guard to normalize inputs that may be Observables or raw values.', + code: `import { Observable, of } from 'rxjs'; +function toObservable(value: T | Observable): Observable { + return isObservable(value) ? value : of(value); +}`, + assert: () => { + if (isObservable(null)) throw new Error('null should return false'); + if (isObservable({ subscribe: () => {} })) throw new Error('missing pipe should return false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/observable/isObservable.spec.ts b/helpers/observable/isObservable.spec.ts new file mode 100644 index 00000000..d347e6a6 --- /dev/null +++ b/helpers/observable/isObservable.spec.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { Observable, Subject } from 'rxjs'; +import { describe, expect, it } from 'vitest'; +import { isObservable } from './isObservable'; + +describe('isObservable — property-based', () => { + it('primitives are never observables', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isObservable(value)).toBe(false); + }), + ); + }); +}); + +describe('isObservable — contract', () => { + it('null → false', () => expect(isObservable(null)).toBe(false)); + it('undefined → false', () => expect(isObservable(undefined)).toBe(false)); + it('{} → false', () => expect(isObservable({})).toBe(false)); + it('Promise → false', () => expect(isObservable(Promise.resolve())).toBe(false)); + it('{ subscribe: fn } only → false (pipe missing)', () => expect(isObservable({ subscribe: () => {} })).toBe(false)); + it('{ pipe: fn } only → false (subscribe missing)', () => expect(isObservable({ pipe: () => {} })).toBe(false)); + it('Observable → true', () => expect(isObservable(new Observable())).toBe(true)); + it('Subject → true', () => expect(isObservable(new Subject())).toBe(true)); + it('non-fn subscribe → false', () => expect(isObservable({ subscribe: 'x', pipe: () => {} })).toBe(false)); + it('non-fn pipe → false', () => expect(isObservable({ subscribe: () => {}, pipe: 'x' })).toBe(false)); +}); diff --git a/helpers/observable/isObservable.test.ts b/helpers/observable/isObservable.test.ts new file mode 100644 index 00000000..bb25ef85 --- /dev/null +++ b/helpers/observable/isObservable.test.ts @@ -0,0 +1,54 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { BehaviorSubject, Observable, ReplaySubject, Subject } from 'rxjs'; +import { describe, expect, it } from 'vitest'; +import { isObservable } from './isObservable'; + +describe('isObservable', () => { + it('should return true for Observable instances', () => { + expect(isObservable(new Observable())).toBe(true); + expect(isObservable(new Observable((obs) => { obs.next(1); }))).toBe(true); + }); + + it('should return true for Subject variants', () => { + expect(isObservable(new Subject())).toBe(true); + expect(isObservable(new BehaviorSubject(0))).toBe(true); + expect(isObservable(new ReplaySubject(1))).toBe(true); + }); + + it('should return true for objects with subscribe and pipe functions', () => { + expect(isObservable({ subscribe: () => {}, pipe: () => {} })).toBe(true); + }); + + it('should return false when subscribe is missing', () => { + expect(isObservable({ pipe: () => {} })).toBe(false); + }); + + it('should return false when pipe is missing', () => { + expect(isObservable({ subscribe: () => {} })).toBe(false); + }); + + it('should return false when methods are not functions', () => { + expect(isObservable({ subscribe: 'x', pipe: () => {} })).toBe(false); + expect(isObservable({ subscribe: () => {}, pipe: 'x' })).toBe(false); + }); + + it('should return false for Promises', () => { + expect(isObservable(Promise.resolve())).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isObservable(null)).toBe(false); + expect(isObservable(undefined)).toBe(false); + }); + + it('should return false for primitives and plain objects', () => { + expect(isObservable(42)).toBe(false); + expect(isObservable('observable')).toBe(false); + expect(isObservable({})).toBe(false); + }); +}); diff --git a/helpers/observable/isObservable.ts b/helpers/observable/isObservable.ts new file mode 100644 index 00000000..68587ed5 --- /dev/null +++ b/helpers/observable/isObservable.ts @@ -0,0 +1,36 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { Observable } from 'rxjs'; + +/** + * Checks if a value is an RxJS Observable or any compatible observable. + * + * Uses duck-typing: returns `true` for any object with both `.subscribe()` and + * `.pipe()` methods, covering `Observable`, `Subject`, `BehaviorSubject`, + * `ReplaySubject`, and any RxJS-compatible observable implementation. + * + * @param value - The value to check + * @returns `true` if value is observable-like + * @example + * import { Observable, Subject } from 'rxjs'; + * isObservable(new Observable()) // => true + * isObservable(new Subject()) // => true + * isObservable(Promise.resolve()) // => false + * isObservable({}) // => false + * @since next + */ +export function isObservable(value: unknown): value is Observable { + return ( + // Intentionally `object`-only, unlike `isPromiseLike` (see type/isPromiseLike.ts): + // a callable function exposing `.subscribe()`/`.pipe()` would be an unusual, + // contrived observable shape. + value !== null && + typeof value === 'object' && + typeof (value as Record)['subscribe'] === 'function' && + typeof (value as Record)['pipe'] === 'function' + ); +} From 66aa7b253c433d6858340e4838f2a9734557030b Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:01:49 +0000 Subject: [PATCH 10/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isArrayLi?= =?UTF-8?q?ke=20function=20and=20examples=20-=20implement=20isArrayLike=20?= =?UTF-8?q?to=20check=20array-like=20values=20-=20add=20examples=20for=20u?= =?UTF-8?q?sage=20and=20assertions=20-=20create=20property-based=20and=20c?= =?UTF-8?q?ontract=20tests=20for=20isArrayLike?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isArrayLike.example.ts | 54 +++++++++++++++++++++++++++ helpers/type/isArrayLike.spec.ts | 58 +++++++++++++++++++++++++++++ helpers/type/isArrayLike.test.ts | 58 +++++++++++++++++++++++++++++ helpers/type/isArrayLike.ts | 32 ++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 helpers/type/isArrayLike.example.ts create mode 100644 helpers/type/isArrayLike.spec.ts create mode 100644 helpers/type/isArrayLike.test.ts create mode 100644 helpers/type/isArrayLike.ts diff --git a/helpers/type/isArrayLike.example.ts b/helpers/type/isArrayLike.example.ts new file mode 100644 index 00000000..39e38189 --- /dev/null +++ b/helpers/type/isArrayLike.example.ts @@ -0,0 +1,54 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isArrayLike } from './isArrayLike'; + +const examples: HelperExamples = { + helper: 'isArrayLike', + category: 'type', + examples: [ + { + title: 'Detect array-like values', + description: 'Arrays, strings, and objects with a non-negative integer length are array-like.', + code: `isArrayLike([1, 2, 3]) // => true +isArrayLike('hello') // => true +isArrayLike({ length: 3 }) // => true +isArrayLike({ length: -1 }) // => false +isArrayLike(() => {}) // => false (functions excluded) +isArrayLike(null) // => false`, + assert: () => { + if (!isArrayLike([1, 2])) throw new Error('array should be array-like'); + if (!isArrayLike('hello')) throw new Error('string should be array-like'); + if (!isArrayLike({ length: 3 })) throw new Error('{length:3} should be array-like'); + if (isArrayLike({ length: -1 })) throw new Error('{length:-1} should not be array-like'); + if (isArrayLike(() => {})) throw new Error('function should not be array-like'); + }, + }, + { + title: 'Convert an array-like value to an array', + description: 'Use as a guard before Array.from().', + code: `function toArray(value: unknown): unknown[] { + if (isArrayLike(value)) return Array.from(value); + return [value]; +} +toArray([1, 2]) // => [1, 2] +toArray('abc') // => ['a', 'b', 'c'] +toArray(42) // => [42]`, + assert: () => { + function toArray(value: unknown): unknown[] { + if (isArrayLike(value)) return Array.from(value); + return [value]; + } + if (toArray([1, 2]).length !== 2) throw new Error('Expected length 2'); + if (toArray('abc').length !== 3) throw new Error('Expected length 3'); + if (toArray(42).length !== 1) throw new Error('Expected [42]'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isArrayLike.spec.ts b/helpers/type/isArrayLike.spec.ts new file mode 100644 index 00000000..be95cf6d --- /dev/null +++ b/helpers/type/isArrayLike.spec.ts @@ -0,0 +1,58 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isArrayLike } from './isArrayLike'; + +describe('isArrayLike — property-based', () => { + it('arrays are always array-like', () => { + fc.assert( + fc.property(fc.array(fc.anything()), (arr) => { + expect(isArrayLike(arr)).toBe(true); + }), + ); + }); + + it('strings are always array-like', () => { + fc.assert( + fc.property(fc.string(), (str) => { + expect(isArrayLike(str)).toBe(true); + }), + ); + }); + + it('objects with non-negative integer length are array-like', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 100_000 }), (len) => { + expect(isArrayLike({ length: len })).toBe(true); + }), + ); + }); + + it('booleans are never array-like', () => { + fc.assert( + fc.property(fc.boolean(), (value) => { + expect(isArrayLike(value)).toBe(false); + }), + ); + }); +}); + +describe('isArrayLike — contract', () => { + it('null → false', () => expect(isArrayLike(null)).toBe(false)); + it('undefined → false', () => expect(isArrayLike(undefined)).toBe(false)); + it('[] → true', () => expect(isArrayLike([])).toBe(true)); + it('"" → true', () => expect(isArrayLike('')).toBe(true)); + it('{ length: 0 } → true', () => expect(isArrayLike({ length: 0 })).toBe(true)); + it('{ length: -1 } → false', () => expect(isArrayLike({ length: -1 })).toBe(false)); + it('{ length: 1.5 } → false', () => expect(isArrayLike({ length: 1.5 })).toBe(false)); + it('{ length: NaN } → false', () => expect(isArrayLike({ length: NaN })).toBe(false)); + it('{ length: Infinity } → false', () => expect(isArrayLike({ length: Infinity })).toBe(false)); + it('{} → false (no length)', () => expect(isArrayLike({})).toBe(false)); + it('function → false', () => expect(isArrayLike(() => {})).toBe(false)); + it('42 → false', () => expect(isArrayLike(42)).toBe(false)); +}); diff --git a/helpers/type/isArrayLike.test.ts b/helpers/type/isArrayLike.test.ts new file mode 100644 index 00000000..ec4d1cb9 --- /dev/null +++ b/helpers/type/isArrayLike.test.ts @@ -0,0 +1,58 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isArrayLike } from './isArrayLike'; + +describe('isArrayLike', () => { + it('should return true for arrays', () => { + expect(isArrayLike([])).toBe(true); + expect(isArrayLike([1, 2, 3])).toBe(true); + }); + + it('should return true for strings', () => { + expect(isArrayLike('')).toBe(true); + expect(isArrayLike('hello')).toBe(true); + }); + + it('should return true for objects with valid length', () => { + expect(isArrayLike({ length: 0 })).toBe(true); + expect(isArrayLike({ length: 3 })).toBe(true); + }); + + it('should return true for typed arrays', () => { + expect(isArrayLike(new Uint8Array(4))).toBe(true); + expect(isArrayLike(new Int32Array(2))).toBe(true); + }); + + it('should return false for objects with invalid length', () => { + expect(isArrayLike({ length: -1 })).toBe(false); + expect(isArrayLike({ length: 1.5 })).toBe(false); + expect(isArrayLike({ length: NaN })).toBe(false); + expect(isArrayLike({ length: Infinity })).toBe(false); + }); + + it('should return false for objects without length', () => { + expect(isArrayLike({})).toBe(false); + expect(isArrayLike({ a: 1 })).toBe(false); + }); + + it('should return false for functions', () => { + expect(isArrayLike(() => {})).toBe(false); + expect(isArrayLike(function named() {})).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isArrayLike(null)).toBe(false); + expect(isArrayLike(undefined)).toBe(false); + }); + + it('should return false for non-object primitives', () => { + expect(isArrayLike(42)).toBe(false); + expect(isArrayLike(true)).toBe(false); + expect(isArrayLike(Symbol('x'))).toBe(false); + }); +}); diff --git a/helpers/type/isArrayLike.ts b/helpers/type/isArrayLike.ts new file mode 100644 index 00000000..1a679c11 --- /dev/null +++ b/helpers/type/isArrayLike.ts @@ -0,0 +1,32 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is array-like: has a non-negative integer `length` property. + * + * Returns `true` for arrays, strings, `arguments` objects, `NodeList`, typed + * arrays, and any object with a valid `length`. Functions are excluded even though + * they have a `length` (arity), as they are not considered array-like in practice. + * + * @param value - The value to check + * @returns `true` if value is array-like + * @example + * isArrayLike([1, 2, 3]) // => true + * isArrayLike('hello') // => true + * isArrayLike({ length: 2 }) // => true + * isArrayLike({ length: -1 }) // => false (negative length) + * isArrayLike({ length: 1.5 }) // => false (non-integer length) + * isArrayLike(() => {}) // => false (functions excluded) + * isArrayLike(null) // => false + * @since next + */ +export function isArrayLike(value: unknown): value is ArrayLike { + if (value == null) return false; + const t = typeof value; + if (t !== 'object' && t !== 'string') return false; + const len = (value as ArrayLike).length; + return typeof len === 'number' && Number.isInteger(len) && len >= 0; +} From 23b5060b440af3c6f7f92452c1c3c2af6818f8d0 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:01:59 +0000 Subject: [PATCH 11/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isAsyncGe?= =?UTF-8?q?nerator=20function=20and=20examples=20-=20implement=20isAsyncGe?= =?UTF-8?q?nerator=20to=20check=20async=20generator=20instances=20-=20add?= =?UTF-8?q?=20examples=20for=20usage=20and=20assertions=20-=20create=20tes?= =?UTF-8?q?ts=20for=20isAsyncGenerator=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isAsyncGenerator.example.ts | 44 ++++++++++++++++++++++++ helpers/type/isAsyncGenerator.spec.ts | 38 ++++++++++++++++++++ helpers/type/isAsyncGenerator.test.ts | 40 +++++++++++++++++++++ helpers/type/isAsyncGenerator.ts | 25 ++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 helpers/type/isAsyncGenerator.example.ts create mode 100644 helpers/type/isAsyncGenerator.spec.ts create mode 100644 helpers/type/isAsyncGenerator.test.ts create mode 100644 helpers/type/isAsyncGenerator.ts diff --git a/helpers/type/isAsyncGenerator.example.ts b/helpers/type/isAsyncGenerator.example.ts new file mode 100644 index 00000000..26014851 --- /dev/null +++ b/helpers/type/isAsyncGenerator.example.ts @@ -0,0 +1,44 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isAsyncGenerator } from './isAsyncGenerator'; + +const examples: HelperExamples = { + helper: 'isAsyncGenerator', + category: 'type', + examples: [ + { + title: 'Detect an async generator instance', + description: 'Returns true only for the object produced by calling an async function*.', + code: `async function* gen() { yield 1; } +isAsyncGenerator(gen()) // => true (instance) +isAsyncGenerator(gen) // => false (function) +isAsyncGenerator([]) // => false`, + assert: () => { + async function* gen() { yield 1; } + if (!isAsyncGenerator(gen())) throw new Error('async generator instance should return true'); + if (isAsyncGenerator(gen)) throw new Error('async generator function should return false'); + }, + }, + { + title: 'Distinguish async from sync generators', + description: 'isAsyncGenerator is false for sync generator instances.', + code: `function* sync() { yield 1; } +async function* async_() { yield 1; } +isAsyncGenerator(sync()) // => false +isAsyncGenerator(async_()) // => true`, + assert: () => { + function* sync() { yield 1; } + async function* asyncGen() { yield 1; } + if (isAsyncGenerator(sync())) throw new Error('sync generator should return false'); + if (!isAsyncGenerator(asyncGen())) throw new Error('async generator should return true'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isAsyncGenerator.spec.ts b/helpers/type/isAsyncGenerator.spec.ts new file mode 100644 index 00000000..fd08763d --- /dev/null +++ b/helpers/type/isAsyncGenerator.spec.ts @@ -0,0 +1,38 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isAsyncGenerator } from './isAsyncGenerator'; + +describe('isAsyncGenerator — property-based', () => { + it('primitives are never async generators', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isAsyncGenerator(value)).toBe(false); + }), + ); + }); +}); + +describe('isAsyncGenerator — contract', () => { + it('null → false', () => expect(isAsyncGenerator(null)).toBe(false)); + it('undefined → false', () => expect(isAsyncGenerator(undefined)).toBe(false)); + it('{} → false', () => expect(isAsyncGenerator({})).toBe(false)); + it('sync generator instance → false', () => { + function* gen() { yield 1; } + expect(isAsyncGenerator(gen())).toBe(false); + }); + it('async generator function → false (not an instance)', () => { + async function* gen() { yield 1; } + expect(isAsyncGenerator(gen)).toBe(false); + }); + it('async generator instance → true', () => { + async function* gen() { yield 1; } + expect(isAsyncGenerator(gen())).toBe(true); + }); + it('Promise → false', () => expect(isAsyncGenerator(Promise.resolve())).toBe(false)); +}); diff --git a/helpers/type/isAsyncGenerator.test.ts b/helpers/type/isAsyncGenerator.test.ts new file mode 100644 index 00000000..943ca63f --- /dev/null +++ b/helpers/type/isAsyncGenerator.test.ts @@ -0,0 +1,40 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isAsyncGenerator } from './isAsyncGenerator'; + +describe('isAsyncGenerator', () => { + it('should return true for async generator instances', () => { + async function* gen() { yield 1; } + expect(isAsyncGenerator(gen())).toBe(true); + }); + + it('should return true for partially consumed async generators', () => { + async function* gen() { yield 1; yield 2; } + const g = gen(); + void g.next(); + expect(isAsyncGenerator(g)).toBe(true); + }); + + it('should return false for async generator functions', () => { + async function* gen() { yield 1; } + expect(isAsyncGenerator(gen)).toBe(false); + }); + + it('should return false for sync generators', () => { + function* gen() { yield 1; } + expect(isAsyncGenerator(gen())).toBe(false); + }); + + it('should return false for other values', () => { + expect(isAsyncGenerator(null)).toBe(false); + expect(isAsyncGenerator(undefined)).toBe(false); + expect(isAsyncGenerator(42)).toBe(false); + expect(isAsyncGenerator({})).toBe(false); + expect(isAsyncGenerator([])).toBe(false); + }); +}); diff --git a/helpers/type/isAsyncGenerator.ts b/helpers/type/isAsyncGenerator.ts new file mode 100644 index 00000000..70bb4b31 --- /dev/null +++ b/helpers/type/isAsyncGenerator.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is an async generator object (the result of calling an `async function*`). + * + * Distinct from {@link isAsyncGeneratorFunction}: this predicate targets the + * *instance* produced by calling an async generator function, not the function itself. + * + * @param value - The value to check + * @returns `true` if value is an AsyncGenerator instance + * @example + * async function* gen() { yield 1; } + * isAsyncGenerator(gen()) // => true + * isAsyncGenerator(gen) // => false (function, not instance) + * isAsyncGenerator([]) // => false + * @see {@link isAsyncGeneratorFunction} + * @since next + */ +export function isAsyncGenerator(value: unknown): value is AsyncGenerator { + return Object.prototype.toString.call(value) === '[object AsyncGenerator]'; +} From cf850ba371555429d6efac9c22f973539c6bc480 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:02:10 +0000 Subject: [PATCH 12/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isAsyncGe?= =?UTF-8?q?neratorFunction=20with=20examples=20and=20tests=20-=20implement?= =?UTF-8?q?=20isAsyncGeneratorFunction=20to=20check=20async=20generator=20?= =?UTF-8?q?functions=20-=20add=20examples=20for=20usage=20in=20isAsyncGene?= =?UTF-8?q?ratorFunction.example.ts=20-=20create=20tests=20for=20isAsyncGe?= =?UTF-8?q?neratorFunction=20in=20isAsyncGeneratorFunction.spec.ts=20-=20a?= =?UTF-8?q?dd=20additional=20tests=20in=20isAsyncGeneratorFunction.test.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../type/isAsyncGeneratorFunction.example.ts | 45 +++++++++++++++++++ helpers/type/isAsyncGeneratorFunction.spec.ts | 41 +++++++++++++++++ helpers/type/isAsyncGeneratorFunction.test.ts | 43 ++++++++++++++++++ helpers/type/isAsyncGeneratorFunction.ts | 25 +++++++++++ 4 files changed, 154 insertions(+) create mode 100644 helpers/type/isAsyncGeneratorFunction.example.ts create mode 100644 helpers/type/isAsyncGeneratorFunction.spec.ts create mode 100644 helpers/type/isAsyncGeneratorFunction.test.ts create mode 100644 helpers/type/isAsyncGeneratorFunction.ts diff --git a/helpers/type/isAsyncGeneratorFunction.example.ts b/helpers/type/isAsyncGeneratorFunction.example.ts new file mode 100644 index 00000000..5a4264bc --- /dev/null +++ b/helpers/type/isAsyncGeneratorFunction.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isAsyncGeneratorFunction } from './isAsyncGeneratorFunction'; + +const examples: HelperExamples = { + helper: 'isAsyncGeneratorFunction', + category: 'type', + examples: [ + { + title: 'Detect an async generator function', + description: 'Returns true for async function* declarations and expressions.', + code: `async function* gen() { yield 1; } +isAsyncGeneratorFunction(gen) // => true +isAsyncGeneratorFunction(gen()) // => false (instance) +isAsyncGeneratorFunction(async () => {}) // => false`, + assert: () => { + async function* gen() { yield 1; } + if (!isAsyncGeneratorFunction(gen)) throw new Error('should be async generator function'); + if (isAsyncGeneratorFunction(gen())) throw new Error('instance should return false'); + if (isAsyncGeneratorFunction(async () => {})) throw new Error('async fn should return false'); + }, + }, + { + title: 'Distinguish async generator functions from sync generator functions', + description: 'isAsyncGeneratorFunction is false for sync function*.', + code: `function* sync() { yield 1; } +async function* async_() { yield 1; } +isAsyncGeneratorFunction(sync) // => false +isAsyncGeneratorFunction(async_) // => true`, + assert: () => { + function* sync() { yield 1; } + async function* asyncGen() { yield 1; } + if (isAsyncGeneratorFunction(sync)) throw new Error('sync gen fn should return false'); + if (!isAsyncGeneratorFunction(asyncGen)) throw new Error('async gen fn should return true'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isAsyncGeneratorFunction.spec.ts b/helpers/type/isAsyncGeneratorFunction.spec.ts new file mode 100644 index 00000000..62419089 --- /dev/null +++ b/helpers/type/isAsyncGeneratorFunction.spec.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isAsyncGeneratorFunction } from './isAsyncGeneratorFunction'; + +describe('isAsyncGeneratorFunction — property-based', () => { + it('primitives are never async generator functions', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isAsyncGeneratorFunction(value)).toBe(false); + }), + ); + }); +}); + +describe('isAsyncGeneratorFunction — contract', () => { + it('null → false', () => expect(isAsyncGeneratorFunction(null)).toBe(false)); + it('undefined → false', () => expect(isAsyncGeneratorFunction(undefined)).toBe(false)); + it('regular function → false', () => expect(isAsyncGeneratorFunction(() => {})).toBe(false)); + it('async function → false', () => expect(isAsyncGeneratorFunction(async () => {})).toBe(false)); + it('sync generator function → false', () => { + function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen)).toBe(false); + }); + it('async generator instance → false (not a function)', () => { + async function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen())).toBe(false); + }); + it('async function* → true', () => { + async function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen)).toBe(true); + }); + it('async function* expression → true', () => { + expect(isAsyncGeneratorFunction(async function* () { yield 1; })).toBe(true); + }); +}); diff --git a/helpers/type/isAsyncGeneratorFunction.test.ts b/helpers/type/isAsyncGeneratorFunction.test.ts new file mode 100644 index 00000000..eb6180dc --- /dev/null +++ b/helpers/type/isAsyncGeneratorFunction.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isAsyncGeneratorFunction } from './isAsyncGeneratorFunction'; + +describe('isAsyncGeneratorFunction', () => { + it('should return true for async function* declarations', () => { + async function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen)).toBe(true); + }); + + it('should return true for async function* expressions', () => { + const gen = async function* () { yield 1; }; + expect(isAsyncGeneratorFunction(gen)).toBe(true); + }); + + it('should return false for async generator instances', () => { + async function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen())).toBe(false); + }); + + it('should return false for sync generator functions', () => { + function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen)).toBe(false); + }); + + it('should return false for regular and async functions', () => { + expect(isAsyncGeneratorFunction(() => {})).toBe(false); + expect(isAsyncGeneratorFunction(async () => {})).toBe(false); + expect(isAsyncGeneratorFunction(function () {})).toBe(false); + }); + + it('should return false for null, undefined and other types', () => { + expect(isAsyncGeneratorFunction(null)).toBe(false); + expect(isAsyncGeneratorFunction(undefined)).toBe(false); + expect(isAsyncGeneratorFunction(42)).toBe(false); + expect(isAsyncGeneratorFunction({})).toBe(false); + }); +}); diff --git a/helpers/type/isAsyncGeneratorFunction.ts b/helpers/type/isAsyncGeneratorFunction.ts new file mode 100644 index 00000000..32f48517 --- /dev/null +++ b/helpers/type/isAsyncGeneratorFunction.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is an async generator function (an `async function*` declaration or expression). + * + * Distinct from {@link isAsyncGenerator}: this predicate targets the *function* itself, + * not the async iterator it produces when called. + * + * @param value - The value to check + * @returns `true` if value is an AsyncGeneratorFunction + * @example + * async function* gen() { yield 1; } + * isAsyncGeneratorFunction(gen) // => true + * isAsyncGeneratorFunction(gen()) // => false (instance, not function) + * isAsyncGeneratorFunction(async () => {}) // => false + * @see {@link isAsyncGenerator} + * @since next + */ +export function isAsyncGeneratorFunction(value: unknown): value is AsyncGeneratorFunction { + return Object.prototype.toString.call(value) === '[object AsyncGeneratorFunction]'; +} From afef1e0d8313fd0fd6953eb9c389e5f817b4696d Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:02:19 +0000 Subject: [PATCH 13/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isAsyncIt?= =?UTF-8?q?erable=20function=20with=20examples=20and=20tests=20-=20impleme?= =?UTF-8?q?nt=20isAsyncIterable=20to=20check=20async=20iterable=20protocol?= =?UTF-8?q?=20-=20add=20examples=20for=20async=20generators=20and=20custom?= =?UTF-8?q?=20async=20iterables=20-=20create=20tests=20for=20various=20cas?= =?UTF-8?q?es=20including=20primitives=20and=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isAsyncIterable.example.ts | 45 +++++++++++++++++++++++++ helpers/type/isAsyncIterable.spec.ts | 38 +++++++++++++++++++++ helpers/type/isAsyncIterable.test.ts | 44 ++++++++++++++++++++++++ helpers/type/isAsyncIterable.ts | 27 +++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 helpers/type/isAsyncIterable.example.ts create mode 100644 helpers/type/isAsyncIterable.spec.ts create mode 100644 helpers/type/isAsyncIterable.test.ts create mode 100644 helpers/type/isAsyncIterable.ts diff --git a/helpers/type/isAsyncIterable.example.ts b/helpers/type/isAsyncIterable.example.ts new file mode 100644 index 00000000..8325d296 --- /dev/null +++ b/helpers/type/isAsyncIterable.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isAsyncIterable } from './isAsyncIterable'; + +const examples: HelperExamples = { + helper: 'isAsyncIterable', + category: 'type', + examples: [ + { + title: 'Detect an async generator', + description: 'Async generators implement the async iterable protocol.', + code: `async function* stream() { yield 1; yield 2; } +isAsyncIterable(stream()) // => true +isAsyncIterable([1, 2, 3]) // => false (Iterable, not AsyncIterable) +isAsyncIterable('hello') // => false`, + assert: () => { + async function* stream() { yield 1; } + if (!isAsyncIterable(stream())) throw new Error('async generator should be async iterable'); + if (isAsyncIterable([1, 2])) throw new Error('array should not be async iterable'); + }, + }, + { + title: 'Guard before for-await-of', + description: 'Use to type-narrow before consuming a value with for-await-of.', + code: `async function consume(source: unknown) { + if (isAsyncIterable(source)) { + for await (const item of source) { + console.log(item); + } + } +}`, + assert: () => { + if (isAsyncIterable(null)) throw new Error('null should not be async iterable'); + if (isAsyncIterable(undefined)) throw new Error('undefined should not be async iterable'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isAsyncIterable.spec.ts b/helpers/type/isAsyncIterable.spec.ts new file mode 100644 index 00000000..fa12e6a4 --- /dev/null +++ b/helpers/type/isAsyncIterable.spec.ts @@ -0,0 +1,38 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isAsyncIterable } from './isAsyncIterable'; + +describe('isAsyncIterable — property-based', () => { + it('primitives are never async iterable', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isAsyncIterable(value)).toBe(false); + }), + ); + }); +}); + +describe('isAsyncIterable — contract', () => { + it('null → false', () => expect(isAsyncIterable(null)).toBe(false)); + it('undefined → false', () => expect(isAsyncIterable(undefined)).toBe(false)); + it('[] → false (Iterable, not AsyncIterable)', () => expect(isAsyncIterable([])).toBe(false)); + it('{} → false', () => expect(isAsyncIterable({})).toBe(false)); + it('async generator instance → true', () => { + async function* gen() { yield 1; } + expect(isAsyncIterable(gen())).toBe(true); + }); + it('custom [Symbol.asyncIterator] object → true', () => { + const obj = { [Symbol.asyncIterator]: () => ({ async next() { return { value: undefined, done: true }; } }) }; + expect(isAsyncIterable(obj)).toBe(true); + }); + it('[Symbol.asyncIterator] must be a function, not a value', () => { + const obj = { [Symbol.asyncIterator]: 'not-a-function' }; + expect(isAsyncIterable(obj)).toBe(false); + }); +}); diff --git a/helpers/type/isAsyncIterable.test.ts b/helpers/type/isAsyncIterable.test.ts new file mode 100644 index 00000000..9264a2a5 --- /dev/null +++ b/helpers/type/isAsyncIterable.test.ts @@ -0,0 +1,44 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isAsyncIterable } from './isAsyncIterable'; + +describe('isAsyncIterable', () => { + it('should return true for async generators', () => { + async function* gen() { yield 1; } + expect(isAsyncIterable(gen())).toBe(true); + }); + + it('should return true for custom async iterable objects', () => { + const obj = { + [Symbol.asyncIterator]() { + return { async next() { return { value: 1, done: true }; } }; + }, + }; + expect(isAsyncIterable(obj)).toBe(true); + }); + + it('should return false for regular iterables (arrays, strings, generators)', () => { + expect(isAsyncIterable([1, 2, 3])).toBe(false); + expect(isAsyncIterable('hello')).toBe(false); + function* gen() { yield 1; } + expect(isAsyncIterable(gen())).toBe(false); + expect(isAsyncIterable(new Map())).toBe(false); + expect(isAsyncIterable(new Set())).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isAsyncIterable(null)).toBe(false); + expect(isAsyncIterable(undefined)).toBe(false); + }); + + it('should return false for plain objects and primitives', () => { + expect(isAsyncIterable({})).toBe(false); + expect(isAsyncIterable(42)).toBe(false); + expect(isAsyncIterable(true)).toBe(false); + }); +}); diff --git a/helpers/type/isAsyncIterable.ts b/helpers/type/isAsyncIterable.ts new file mode 100644 index 00000000..75276f22 --- /dev/null +++ b/helpers/type/isAsyncIterable.ts @@ -0,0 +1,27 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value implements the async iterable protocol. + * + * Returns `true` for any object that has a `[Symbol.asyncIterator]()` method, + * including async generators. Note that regular iterables (arrays, strings, etc.) + * are **not** async iterables. + * + * @param value - The value to check + * @returns `true` if value is async iterable + * @example + * async function* gen() { yield 1; } + * isAsyncIterable(gen()) // => true + * isAsyncIterable([1, 2, 3]) // => false (Iterable, not AsyncIterable) + * isAsyncIterable('hello') // => false + * isAsyncIterable(null) // => false + * @since next + */ +export function isAsyncIterable(value: unknown): value is AsyncIterable { + if (value === null || value === undefined) return false; + return typeof (value as AsyncIterable)[Symbol.asyncIterator] === 'function'; +} From 19887b3999a9d5454e234b66105d4f5cf4889c05 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:02:28 +0000 Subject: [PATCH 14/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isGenerat?= =?UTF-8?q?or=20function=20and=20related=20examples=20and=20tests=20-=20im?= =?UTF-8?q?plement=20isGenerator=20function=20to=20check=20for=20generator?= =?UTF-8?q?=20instances=20-=20add=20examples=20demonstrating=20usage=20of?= =?UTF-8?q?=20isGenerator=20-=20create=20property-based=20and=20contract?= =?UTF-8?q?=20tests=20for=20isGenerator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isGenerator.example.ts | 47 +++++++++++++++++++++++++++++ helpers/type/isGenerator.spec.ts | 42 ++++++++++++++++++++++++++ helpers/type/isGenerator.test.ts | 45 +++++++++++++++++++++++++++ helpers/type/isGenerator.ts | 25 +++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 helpers/type/isGenerator.example.ts create mode 100644 helpers/type/isGenerator.spec.ts create mode 100644 helpers/type/isGenerator.test.ts create mode 100644 helpers/type/isGenerator.ts diff --git a/helpers/type/isGenerator.example.ts b/helpers/type/isGenerator.example.ts new file mode 100644 index 00000000..0f1303fa --- /dev/null +++ b/helpers/type/isGenerator.example.ts @@ -0,0 +1,47 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isGenerator } from './isGenerator'; + +const examples: HelperExamples = { + helper: 'isGenerator', + category: 'type', + examples: [ + { + title: 'Distinguish a generator instance from its function', + description: 'isGenerator targets the object returned by calling a function*, not the function itself.', + code: `function* counter() { yield 1; yield 2; } +isGenerator(counter()) // => true (instance) +isGenerator(counter) // => false (function) +isGenerator([1, 2]) // => false`, + assert: () => { + function* counter() { yield 1; } + if (!isGenerator(counter())) throw new Error('generator instance should return true'); + if (isGenerator(counter)) throw new Error('generator function should return false'); + }, + }, + { + title: 'Type-narrow to safely call .next()', + description: 'Narrows the type to Generator so you can call .next() and .return().', + code: `function* gen() { yield 1; yield 2; } +const value: unknown = gen(); +if (isGenerator(value)) { + const { value: v, done } = value.next(); + // v: unknown, done: boolean | undefined +}`, + assert: () => { + function* gen() { yield 1; } + const g: unknown = gen(); + if (!isGenerator(g)) throw new Error('should be generator'); + const { value } = g.next(); + if (value !== 1) throw new Error('first value should be 1'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isGenerator.spec.ts b/helpers/type/isGenerator.spec.ts new file mode 100644 index 00000000..44823568 --- /dev/null +++ b/helpers/type/isGenerator.spec.ts @@ -0,0 +1,42 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isGenerator } from './isGenerator'; + +describe('isGenerator — property-based', () => { + it('primitives are never generators', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isGenerator(value)).toBe(false); + }), + ); + }); +}); + +describe('isGenerator — contract', () => { + it('null → false', () => expect(isGenerator(null)).toBe(false)); + it('undefined → false', () => expect(isGenerator(undefined)).toBe(false)); + it('{} → false', () => expect(isGenerator({})).toBe(false)); + it('[] → false', () => expect(isGenerator([])).toBe(false)); + it('regular function → false', () => expect(isGenerator(() => {})).toBe(false)); + it('generator function → false (not an instance)', () => { + function* gen() { yield 1; } + expect(isGenerator(gen)).toBe(false); + }); + it('generator instance → true', () => { + function* gen() { yield 1; } + expect(isGenerator(gen())).toBe(true); + }); + it('async generator instance → false', () => { + async function* gen() { yield 1; } + expect(isGenerator(gen())).toBe(false); + }); + it('array iterator → false', () => { + expect(isGenerator([1][Symbol.iterator]())).toBe(false); + }); +}); diff --git a/helpers/type/isGenerator.test.ts b/helpers/type/isGenerator.test.ts new file mode 100644 index 00000000..89d5e3bd --- /dev/null +++ b/helpers/type/isGenerator.test.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isGenerator } from './isGenerator'; + +describe('isGenerator', () => { + it('should return true for generator instances', () => { + function* gen() { yield 1; yield 2; } + expect(isGenerator(gen())).toBe(true); + }); + + it('should return true for partially consumed generators', () => { + function* gen() { yield 1; yield 2; } + const g = gen(); + g.next(); + expect(isGenerator(g)).toBe(true); + }); + + it('should return false for generator functions', () => { + function* gen() { yield 1; } + expect(isGenerator(gen)).toBe(false); + }); + + it('should return false for async generators', () => { + async function* asyncGen() { yield 1; } + expect(isGenerator(asyncGen())).toBe(false); + }); + + it('should return false for arrays, iterators and other iterables', () => { + expect(isGenerator([1, 2, 3])).toBe(false); + expect(isGenerator([1][Symbol.iterator]())).toBe(false); + expect(isGenerator('hello')).toBe(false); + }); + + it('should return false for null, undefined and primitives', () => { + expect(isGenerator(null)).toBe(false); + expect(isGenerator(undefined)).toBe(false); + expect(isGenerator(42)).toBe(false); + expect(isGenerator({})).toBe(false); + }); +}); diff --git a/helpers/type/isGenerator.ts b/helpers/type/isGenerator.ts new file mode 100644 index 00000000..d0b0cea0 --- /dev/null +++ b/helpers/type/isGenerator.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a generator object (the result of calling a `function*`). + * + * Distinct from {@link isGeneratorFunction}: this predicate targets the + * *instance* produced by calling a generator function, not the function itself. + * + * @param value - The value to check + * @returns `true` if value is a Generator instance + * @example + * function* gen() { yield 1; yield 2; } + * isGenerator(gen()) // => true + * isGenerator(gen) // => false (function, not instance) + * isGenerator([1, 2]) // => false + * @see {@link isGeneratorFunction} + * @since next + */ +export function isGenerator(value: unknown): value is Generator { + return Object.prototype.toString.call(value) === '[object Generator]'; +} From b76f4573284e4fcb1352123ff7f40b8f9444c4b5 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:02:37 +0000 Subject: [PATCH 15/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isGenerat?= =?UTF-8?q?orFunction=20and=20related=20examples=20and=20tests=20-=20imple?= =?UTF-8?q?ment=20isGeneratorFunction=20to=20check=20for=20generator=20fun?= =?UTF-8?q?ctions=20-=20add=20examples=20for=20detecting=20generator=20fun?= =?UTF-8?q?ctions=20-=20create=20property-based=20and=20contract=20tests?= =?UTF-8?q?=20for=20isGeneratorFunction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isGeneratorFunction.example.ts | 43 +++++++++++++++++++++ helpers/type/isGeneratorFunction.spec.ts | 41 ++++++++++++++++++++ helpers/type/isGeneratorFunction.test.ts | 43 +++++++++++++++++++++ helpers/type/isGeneratorFunction.ts | 25 ++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 helpers/type/isGeneratorFunction.example.ts create mode 100644 helpers/type/isGeneratorFunction.spec.ts create mode 100644 helpers/type/isGeneratorFunction.test.ts create mode 100644 helpers/type/isGeneratorFunction.ts diff --git a/helpers/type/isGeneratorFunction.example.ts b/helpers/type/isGeneratorFunction.example.ts new file mode 100644 index 00000000..04a98a45 --- /dev/null +++ b/helpers/type/isGeneratorFunction.example.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isGeneratorFunction } from './isGeneratorFunction'; + +const examples: HelperExamples = { + helper: 'isGeneratorFunction', + category: 'type', + examples: [ + { + title: 'Detect a generator function', + description: 'Returns true for function* declarations and expressions.', + code: `function* gen() { yield 1; } +isGeneratorFunction(gen) // => true +isGeneratorFunction(gen()) // => false (instance, not function) +isGeneratorFunction(() => {}) // => false`, + assert: () => { + function* gen() { yield 1; } + if (!isGeneratorFunction(gen)) throw new Error('should be generator function'); + if (isGeneratorFunction(gen())) throw new Error('instance should not be generator function'); + if (isGeneratorFunction(() => {})) throw new Error('arrow fn should not be generator function'); + }, + }, + { + title: 'Filter generator factories from a mixed array', + description: 'Use as a predicate to select only generator functions.', + code: `const fns = [() => {}, function* () { yield 1; }, async () => {}]; +fns.filter(isGeneratorFunction) +// => [function* () { yield 1; }]`, + assert: () => { + const fns = [() => {}, function* () { yield 1; }, async () => {}]; + const result = fns.filter(isGeneratorFunction); + if (result.length !== 1) throw new Error('Expected exactly one generator function'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isGeneratorFunction.spec.ts b/helpers/type/isGeneratorFunction.spec.ts new file mode 100644 index 00000000..dba078ac --- /dev/null +++ b/helpers/type/isGeneratorFunction.spec.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isGeneratorFunction } from './isGeneratorFunction'; + +describe('isGeneratorFunction — property-based', () => { + it('primitives are never generator functions', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isGeneratorFunction(value)).toBe(false); + }), + ); + }); +}); + +describe('isGeneratorFunction — contract', () => { + it('null → false', () => expect(isGeneratorFunction(null)).toBe(false)); + it('undefined → false', () => expect(isGeneratorFunction(undefined)).toBe(false)); + it('regular function → false', () => expect(isGeneratorFunction(() => {})).toBe(false)); + it('async function → false', () => expect(isGeneratorFunction(async () => {})).toBe(false)); + it('generator instance → false (not a function)', () => { + function* gen() { yield 1; } + expect(isGeneratorFunction(gen())).toBe(false); + }); + it('async generator function → false', () => { + async function* gen() { yield 1; } + expect(isGeneratorFunction(gen)).toBe(false); + }); + it('function* → true', () => { + function* gen() { yield 1; } + expect(isGeneratorFunction(gen)).toBe(true); + }); + it('function* expression → true', () => { + expect(isGeneratorFunction(function* () { yield 1; })).toBe(true); + }); +}); diff --git a/helpers/type/isGeneratorFunction.test.ts b/helpers/type/isGeneratorFunction.test.ts new file mode 100644 index 00000000..d85e4ec8 --- /dev/null +++ b/helpers/type/isGeneratorFunction.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isGeneratorFunction } from './isGeneratorFunction'; + +describe('isGeneratorFunction', () => { + it('should return true for function* declarations', () => { + function* gen() { yield 1; } + expect(isGeneratorFunction(gen)).toBe(true); + }); + + it('should return true for function* expressions', () => { + const gen = function* () { yield 1; }; + expect(isGeneratorFunction(gen)).toBe(true); + }); + + it('should return false for generator instances', () => { + function* gen() { yield 1; } + expect(isGeneratorFunction(gen())).toBe(false); + }); + + it('should return false for async generator functions', () => { + async function* gen() { yield 1; } + expect(isGeneratorFunction(gen)).toBe(false); + }); + + it('should return false for regular functions', () => { + expect(isGeneratorFunction(() => {})).toBe(false); + expect(isGeneratorFunction(function () {})).toBe(false); + expect(isGeneratorFunction(async () => {})).toBe(false); + }); + + it('should return false for null, undefined and other types', () => { + expect(isGeneratorFunction(null)).toBe(false); + expect(isGeneratorFunction(undefined)).toBe(false); + expect(isGeneratorFunction(42)).toBe(false); + expect(isGeneratorFunction({})).toBe(false); + }); +}); diff --git a/helpers/type/isGeneratorFunction.ts b/helpers/type/isGeneratorFunction.ts new file mode 100644 index 00000000..5fdaf5ca --- /dev/null +++ b/helpers/type/isGeneratorFunction.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a generator function (a `function*` declaration or expression). + * + * Distinct from {@link isGenerator}: this predicate targets the *function* itself, + * not the iterator it produces when called. + * + * @param value - The value to check + * @returns `true` if value is a GeneratorFunction + * @example + * function* gen() { yield 1; } + * isGeneratorFunction(gen) // => true + * isGeneratorFunction(gen()) // => false (instance, not function) + * isGeneratorFunction(() => {}) // => false + * @see {@link isGenerator} + * @since next + */ +export function isGeneratorFunction(value: unknown): value is GeneratorFunction { + return Object.prototype.toString.call(value) === '[object GeneratorFunction]'; +} From cb59307e8dcc6fb3b0e478dee7de4d293cf3a485 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:02:45 +0000 Subject: [PATCH 16/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isPromise?= =?UTF-8?q?Like=20function=20with=20examples=20and=20tests=20-=20implement?= =?UTF-8?q?=20isPromiseLike=20to=20check=20for=20thenable=20objects=20-=20?= =?UTF-8?q?add=20examples=20for=20usage=20of=20isPromiseLike=20-=20create?= =?UTF-8?q?=20tests=20for=20isPromiseLike=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isPromiseLike.example.ts | 43 +++++++++++++++++++++++ helpers/type/isPromiseLike.spec.ts | 35 +++++++++++++++++++ helpers/type/isPromiseLike.test.ts | 49 +++++++++++++++++++++++++++ helpers/type/isPromiseLike.ts | 34 +++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 helpers/type/isPromiseLike.example.ts create mode 100644 helpers/type/isPromiseLike.spec.ts create mode 100644 helpers/type/isPromiseLike.test.ts create mode 100644 helpers/type/isPromiseLike.ts diff --git a/helpers/type/isPromiseLike.example.ts b/helpers/type/isPromiseLike.example.ts new file mode 100644 index 00000000..3580567d --- /dev/null +++ b/helpers/type/isPromiseLike.example.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isPromiseLike } from './isPromiseLike'; + +const examples: HelperExamples = { + helper: 'isPromiseLike', + category: 'type', + examples: [ + { + title: 'Detect any thenable', + description: 'Returns true for native Promises and any object with a .then() method.', + code: `isPromiseLike(Promise.resolve(1)) // => true +isPromiseLike({ then: () => {} }) // => true (thenable) +isPromiseLike(42) // => false +isPromiseLike(null) // => false +isPromiseLike({ then: 'not-a-fn' }) // => false`, + assert: () => { + if (!isPromiseLike(Promise.resolve(1))) throw new Error('Promise should be PromiseLike'); + if (!isPromiseLike({ then: () => {} })) throw new Error('thenable should be PromiseLike'); + if (isPromiseLike(42)) throw new Error('number should not be PromiseLike'); + if (isPromiseLike({ then: 'not-a-fn' })) throw new Error('non-fn then should return false'); + }, + }, + { + title: 'Handle both Promises and thenables in a utility', + description: 'Use isPromiseLike to accept any thenable, not just native Promises.', + code: `function toPromise(value: T | PromiseLike): Promise { + if (isPromiseLike(value)) return Promise.resolve(value); + return Promise.resolve(value); +}`, + assert: () => { + if (!isPromiseLike(new Promise(() => {}))) throw new Error('Promise should pass'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isPromiseLike.spec.ts b/helpers/type/isPromiseLike.spec.ts new file mode 100644 index 00000000..919ab7d8 --- /dev/null +++ b/helpers/type/isPromiseLike.spec.ts @@ -0,0 +1,35 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isPromiseLike } from './isPromiseLike'; + +describe('isPromiseLike — property-based', () => { + it('primitives are never PromiseLike', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isPromiseLike(value)).toBe(false); + }), + ); + }); +}); + +describe('isPromiseLike — contract', () => { + it('null → false', () => expect(isPromiseLike(null)).toBe(false)); + it('undefined → false', () => expect(isPromiseLike(undefined)).toBe(false)); + it('Promise.resolve() → true', () => expect(isPromiseLike(Promise.resolve())).toBe(true)); + it('{ then: fn } → true', () => expect(isPromiseLike({ then: () => {} })).toBe(true)); + it('{ then: non-fn } → false', () => expect(isPromiseLike({ then: 42 })).toBe(false)); + it('{} → false', () => expect(isPromiseLike({})).toBe(false)); + it('function with .then → true', () => { + const fn = Object.assign(() => {}, { then: () => {} }); + expect(isPromiseLike(fn)).toBe(true); + }); + it('function without .then → false', () => { + expect(isPromiseLike(() => {})).toBe(false); + }); +}); diff --git a/helpers/type/isPromiseLike.test.ts b/helpers/type/isPromiseLike.test.ts new file mode 100644 index 00000000..6d762570 --- /dev/null +++ b/helpers/type/isPromiseLike.test.ts @@ -0,0 +1,49 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isPromiseLike } from './isPromiseLike'; + +describe('isPromiseLike', () => { + it('should return true for native Promises', () => { + expect(isPromiseLike(Promise.resolve(1))).toBe(true); + expect(isPromiseLike(new Promise(() => {}))).toBe(true); + expect(isPromiseLike(Promise.reject(new Error('x')).catch(() => {}))).toBe(true); + }); + + it('should return true for objects with a then method', () => { + expect(isPromiseLike({ then: () => {} })).toBe(true); + expect(isPromiseLike({ then: () => {}, catch: () => {} })).toBe(true); + }); + + it('should return true for functions with a then method', () => { + const fn = Object.assign(() => {}, { then: () => {} }); + expect(isPromiseLike(fn)).toBe(true); + }); + + it('should return false when then is not a function', () => { + expect(isPromiseLike({ then: 'not-a-function' })).toBe(false); + expect(isPromiseLike({ then: null })).toBe(false); + expect(isPromiseLike({ then: 42 })).toBe(false); + }); + + it('should return false for null', () => { + expect(isPromiseLike(null)).toBe(false); + }); + + it('should return false for primitives', () => { + expect(isPromiseLike(42)).toBe(false); + expect(isPromiseLike('hello')).toBe(false); + expect(isPromiseLike(true)).toBe(false); + expect(isPromiseLike(Symbol('x'))).toBe(false); + expect(isPromiseLike(undefined)).toBe(false); + }); + + it('should return false for objects without then', () => { + expect(isPromiseLike({})).toBe(false); + expect(isPromiseLike([])).toBe(false); + }); +}); diff --git a/helpers/type/isPromiseLike.ts b/helpers/type/isPromiseLike.ts new file mode 100644 index 00000000..61a6926e --- /dev/null +++ b/helpers/type/isPromiseLike.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a thenable (has a `.then()` method). + * + * Looser than {@link isPromise}: accepts any object or function with a `then` + * method, including non-standard Promise implementations without `.catch()`. + * Follows the Promise/A+ specification for thenables. + * + * @param value - The value to check + * @returns `true` if value is a PromiseLike (thenable) + * @example + * isPromiseLike(Promise.resolve(1)) // => true + * isPromiseLike({ then: () => {} }) // => true (thenable) + * isPromiseLike({ then: 'not-a-function' }) // => false + * isPromiseLike(42) // => false + * isPromiseLike(null) // => false + * @see {@link isPromise} for a stricter check that also requires `.catch()` + * @since next + */ +export function isPromiseLike(value: unknown): value is PromiseLike { + return ( + // Unlike isNodeStream/isObservable, also accepts `function`: a callable + // exposing `.then()` (e.g. an async function with a `then` property attached) + // is a realistic thenable shape per the Promise/A+ spec. + value !== null && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as PromiseLike).then === 'function' + ); +} From dd37945b333676070986cf9e45b9f8a9770de4c5 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:02:54 +0000 Subject: [PATCH 17/34] =?UTF-8?q?feat(type):=20=E2=9C=A8=20add=20isPropert?= =?UTF-8?q?yKey=20function=20with=20examples=20and=20tests=20-=20implement?= =?UTF-8?q?=20isPropertyKey=20to=20validate=20property=20keys=20-=20add=20?= =?UTF-8?q?examples=20for=20valid=20and=20invalid=20property=20keys=20-=20?= =?UTF-8?q?create=20property-based=20tests=20for=20isPropertyKey=20-=20add?= =?UTF-8?q?=20contract=20tests=20for=20isPropertyKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isPropertyKey.example.ts | 51 +++++++++++++++++++++++++++ helpers/type/isPropertyKey.spec.ts | 48 +++++++++++++++++++++++++ helpers/type/isPropertyKey.test.ts | 50 ++++++++++++++++++++++++++ helpers/type/isPropertyKey.ts | 24 +++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 helpers/type/isPropertyKey.example.ts create mode 100644 helpers/type/isPropertyKey.spec.ts create mode 100644 helpers/type/isPropertyKey.test.ts create mode 100644 helpers/type/isPropertyKey.ts diff --git a/helpers/type/isPropertyKey.example.ts b/helpers/type/isPropertyKey.example.ts new file mode 100644 index 00000000..c6b42279 --- /dev/null +++ b/helpers/type/isPropertyKey.example.ts @@ -0,0 +1,51 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isPropertyKey } from './isPropertyKey'; + +const examples: HelperExamples = { + helper: 'isPropertyKey', + category: 'type', + examples: [ + { + title: 'Detect valid property keys', + description: 'Strings, numbers, and symbols are valid property keys.', + code: `isPropertyKey('name') // => true +isPropertyKey(42) // => true +isPropertyKey(Symbol('id')) // => true +isPropertyKey(null) // => false +isPropertyKey(true) // => false`, + assert: () => { + if (!isPropertyKey('name')) throw new Error("'name' should be a property key"); + if (!isPropertyKey(42)) throw new Error('42 should be a property key'); + if (!isPropertyKey(Symbol('id'))) throw new Error('Symbol should be a property key'); + if (isPropertyKey(null)) throw new Error('null should not be a property key'); + if (isPropertyKey(true)) throw new Error('boolean should not be a property key'); + }, + }, + { + title: 'Safe dynamic property access', + description: 'Use as a guard before indexing an object with an unknown key.', + code: `function get(obj: Record, key: unknown): unknown { + if (isPropertyKey(key)) return obj[key]; + return undefined; +} +get({ a: 1 }, 'a') // => 1 +get({ a: 1 }, null) // => undefined`, + assert: () => { + function get(obj: Record, key: unknown): unknown { + if (isPropertyKey(key)) return obj[key]; + return undefined; + } + if (get({ a: 1 }, 'a') !== 1) throw new Error('Expected 1'); + if (get({ a: 1 }, null) !== undefined) throw new Error('Expected undefined'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isPropertyKey.spec.ts b/helpers/type/isPropertyKey.spec.ts new file mode 100644 index 00000000..e5908fab --- /dev/null +++ b/helpers/type/isPropertyKey.spec.ts @@ -0,0 +1,48 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isPropertyKey } from './isPropertyKey'; + +describe('isPropertyKey — property-based', () => { + it('any string is a property key', () => { + fc.assert( + fc.property(fc.string(), (value) => { + expect(isPropertyKey(value)).toBe(true); + }), + ); + }); + + it('any number is a property key', () => { + fc.assert( + fc.property(fc.float({ noNaN: false }), (value) => { + expect(isPropertyKey(value)).toBe(true); + }), + ); + }); + + it('booleans are never property keys', () => { + fc.assert( + fc.property(fc.boolean(), (value) => { + expect(isPropertyKey(value)).toBe(false); + }), + ); + }); +}); + +describe('isPropertyKey — contract', () => { + it('"" → true', () => expect(isPropertyKey('')).toBe(true)); + it('0 → true', () => expect(isPropertyKey(0)).toBe(true)); + it('NaN → true (NaN is a number)', () => expect(isPropertyKey(NaN)).toBe(true)); + it('Symbol() → true', () => expect(isPropertyKey(Symbol())).toBe(true)); + it('Symbol.iterator → true', () => expect(isPropertyKey(Symbol.iterator)).toBe(true)); + it('null → false', () => expect(isPropertyKey(null)).toBe(false)); + it('undefined → false', () => expect(isPropertyKey(undefined)).toBe(false)); + it('{} → false', () => expect(isPropertyKey({})).toBe(false)); + it('[] → false', () => expect(isPropertyKey([])).toBe(false)); + it('BigInt → false', () => expect(isPropertyKey(BigInt(1))).toBe(false)); +}); diff --git a/helpers/type/isPropertyKey.test.ts b/helpers/type/isPropertyKey.test.ts new file mode 100644 index 00000000..68afac7a --- /dev/null +++ b/helpers/type/isPropertyKey.test.ts @@ -0,0 +1,50 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isPropertyKey } from './isPropertyKey'; + +describe('isPropertyKey', () => { + it('should return true for strings', () => { + expect(isPropertyKey('')).toBe(true); + expect(isPropertyKey('name')).toBe(true); + expect(isPropertyKey('0')).toBe(true); + }); + + it('should return true for numbers', () => { + expect(isPropertyKey(0)).toBe(true); + expect(isPropertyKey(42)).toBe(true); + expect(isPropertyKey(-1)).toBe(true); + expect(isPropertyKey(NaN)).toBe(true); + expect(isPropertyKey(Infinity)).toBe(true); + }); + + it('should return true for symbols', () => { + expect(isPropertyKey(Symbol('id'))).toBe(true); + expect(isPropertyKey(Symbol.iterator)).toBe(true); + expect(isPropertyKey(Symbol.for('key'))).toBe(true); + }); + + it('should return false for null and undefined', () => { + expect(isPropertyKey(null)).toBe(false); + expect(isPropertyKey(undefined)).toBe(false); + }); + + it('should return false for booleans', () => { + expect(isPropertyKey(true)).toBe(false); + expect(isPropertyKey(false)).toBe(false); + }); + + it('should return false for objects and arrays', () => { + expect(isPropertyKey({})).toBe(false); + expect(isPropertyKey([])).toBe(false); + expect(isPropertyKey(() => {})).toBe(false); + }); + + it('should return false for bigint', () => { + expect(isPropertyKey(BigInt(1))).toBe(false); + }); +}); diff --git a/helpers/type/isPropertyKey.ts b/helpers/type/isPropertyKey.ts new file mode 100644 index 00000000..f4421d7a --- /dev/null +++ b/helpers/type/isPropertyKey.ts @@ -0,0 +1,24 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a valid property key: `string`, `number`, or `symbol`. + * + * @param value - The value to check + * @returns `true` if value can be used as an object property key + * @example + * isPropertyKey('name') // => true + * isPropertyKey(42) // => true + * isPropertyKey(Symbol('id')) // => true + * isPropertyKey(null) // => false + * isPropertyKey({}) // => false + * isPropertyKey(true) // => false + * @since next + */ +export function isPropertyKey(value: unknown): value is PropertyKey { + const t = typeof value; + return t === 'string' || t === 'number' || t === 'symbol'; +} From d7a15fa5c10bf23eaa524f81e32310dba1e15d90 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:03:16 +0000 Subject: [PATCH 18/34] =?UTF-8?q?docs:=20=F0=9F=93=9D=20update=20isEmpty?= =?UTF-8?q?=20helper=20to=20split=20by=20category=20-=20clarify=20current?= =?UTF-8?q?=20state=20and=20decision=20for=20isEmpty=20-=20outline=20plann?= =?UTF-8?q?ed=20helpers=20for=20array,=20string,=20and=20object=20-=20addr?= =?UTF-8?q?ess=20open=20questions=20regarding=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 19 +++++++++++ TODO.md | 94 ++++++++++--------------------------------------------- 2 files changed, 36 insertions(+), 77 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5194d5e6..8cb811dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,25 @@ helpers// **Coverage:** 100% lines, functions, branches, statements — no exceptions. +### Helper Placement + +**Type predicates** (`is`) → `type/` category. +These answer "what *type* is this value?" and return a TypeScript type guard. +Examples: `isArray`, `isString`, `isNull`, `isPromise`. + +**State predicates** → **their own category**, never `type/`. +These answer "what *state* is this value in?" and are category-specific. +Category-specific examples: + +- `isEmpty` for arrays → `array/isEmpty` (not `type/isEmpty`) +- `isEmpty` for objects → `object/isEmpty` +- `isEmpty` for strings → `string/isEmpty` +- `isNonEmpty` for arrays → `array/isNonEmpty` + +The distinction: a type predicate narrows the TypeScript type (`value is T`); a state predicate +checks a runtime condition within an already-known type context (e.g. an array that happens to be +empty). Mixing both in `type/` blurs the category boundary and makes callers import unrelated logic. + **Intentional cross-category duplicates:** `compact` and `equalsShallow` exist in both `array/` and `object/`. Do **NOT** merge or deduplicate them — each category is an independent npm package and cross-package imports break tree-shaking. ### License Header (required on all source files) diff --git a/TODO.md b/TODO.md index 8a99cc59..43617b9c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,90 +1,32 @@ # TODO — `helpers4/typescript` -> Last refresh: 2026-05-13. +> Last refresh: 2026-06-14. Legend: 🔴 High priority · 🟡 Medium · 🟢 Low --- -## 1. `type/` — gap fill +## 1. `isEmpty` — split by category -**Source:** [radashi-org/discussions#46](https://github.com/orgs/radashi-org/discussions/46#discussioncomment-11736331) -— comparison table of Radashi / another lib / `@sindresorhus/is` (~90 predicates). -Radashi won't act on this. helpers4 can close the relevant gaps since the design is already -tree-shakeable, browser-safe, and one-file-per-predicate. +**Current state:** `type/isEmpty` is a monolithic helper covering string, array, Map, Set, and plain +objects in a single function. -> Note: `isNumber(NaN) === false` is already correct in helpers4 — not affected by Radash #405. +**Decision:** `isEmpty` checks *state*, not *type* — it does not belong in `type/`. Each category +should own its focused predicate. See the **Helper Placement** rule in `AGENTS.md`. -### 🔴 Numeric — common, frequently needed +**Planned helpers:** -| Helper | Implementation note | -|--------|---------------------| -| `isInteger` | `Number.isInteger(value)` — distinct from `isNumber` | -| `isNaN` | `Number.isNaN(value)` — safe version (not the legacy global) | -| `isSafeInteger` | `Number.isSafeInteger(value)` | -| `isInfinite` | `value === Infinity \|\| value === -Infinity` | +- **`isEmpty`** (`array/`) — `Array.isArray(value) && value.length === 0` +- **`isEmpty`** (`string/`) — `value === ''` +- **`isEmpty`** (`object/`) — `isPlainObject(value) && Object.keys(value).length === 0` +- **`isNonEmpty`** (`array/`) — inverse; complements `isNonEmptyArray` currently in `type/` +- **`isNonEmpty`** (`object/`) — inverse -### 🔴 Collections — used widely +**Open questions before implementation:** -| Helper | Implementation note | -|--------|---------------------| -| `isSet` | `value instanceof Set` | -| `isWeakMap` | `value instanceof WeakMap` | -| `isWeakSet` | `value instanceof WeakSet` | -| `isWeakRef` | `value instanceof WeakRef` | - -### 🟡 Numeric — nice-to-have - -| Helper | Implementation note | -|--------|---------------------| -| `isEvenInteger` | `isInteger(n) && n % 2 === 0` | -| `isOddInteger` | `isInteger(n) && n % 2 !== 0` | - -### 🟡 Iteration protocol - -| Helper | Implementation note | -|--------|---------------------| -| `isAsyncIterable` | `Symbol.asyncIterator in Object(value)` | -| `isGenerator` | `Object.prototype.toString` → `[object Generator]` | -| `isGeneratorFunction` | `Object.prototype.toString` → `[object GeneratorFunction]` | -| `isAsyncGenerator` | `Object.prototype.toString` → `[object AsyncGenerator]` | -| `isAsyncGeneratorFunction` | `Object.prototype.toString` → `[object AsyncGeneratorFunction]` | - -### 🟡 String specializations - -| Helper | Implementation note | -|--------|---------------------| -| `isEmptyString` | `value === ''` | -| `isWhitespaceString` | `isString(value) && value.trim() === ''` | - -> `isNonEmptyString` already exists. - -### 🟡 Object / Array specializations - -| Helper | Implementation note | -|--------|---------------------| -| `isEmptyArray` | `isArray(value) && value.length === 0` | -| `isEmptyObject` | `isPlainObject(value) && Object.keys(value).length === 0` | -| `isNonEmptyObject` | `isPlainObject(value) && Object.keys(value).length > 0` | - -### 🟢 General purpose - -| Helper | Implementation note | -|--------|---------------------| -| `isPropertyKey` | `isString(v) \|\| isNumber(v) \|\| isSymbol(v)` → `value is PropertyKey` | -| `isPromiseLike` | `value != null && typeof (value as any).then === 'function'` (thenable) | -| `isArrayLike` | `value != null && typeof (value as any).length === 'number'` | -| `isHtmlElement` | `typeof HTMLElement !== 'undefined' && value instanceof HTMLElement` — browser-only, document it | -| `isUrlInstance` | `value instanceof URL` | - -### Explicitly out of scope - -- Typed arrays (`Int8Array`, `Uint8Array`, etc.) — too niche, no tree-shaking benefit -- `isNodeStream`, `isSharedArrayBuffer` — Node.js specific -- `isObservable` — handled by the `observable/` category -- `isAll` / `isAny` / global `is` / `assert` — meta-predicates, different design surface -- `isClass`, `isBoundFunction`, `isTagged`, `isDirectInstanceOf`, `isEnumCase` — reflection / meta -- `isResult` / `isResultOk` / `isResultErr` — requires a Result type not shipped by this lib +- [ ] Deprecate or keep `type/isEmpty`? (currently the only multi-type predicate — exception to the rule) +- [ ] Should `isNonEmptyArray` and `isNonEmptyString` (currently in `type/`) move to `array/` and `string/`? +- [ ] Naming: `object/isNonEmpty` vs `object/isNonEmptyObject`? --- @@ -149,7 +91,5 @@ After each PR, re-run Scorecard and capture the delta. ## 3. Suggested next steps -1. **`type/` gap fill** — tackle §1 numeric + collection predicates first (high value, small effort). - One file per predicate following the existing pattern. +1. **`isEmpty` split** — create `array/isEmpty`, `string/isEmpty`, `object/isEmpty`; resolve open questions (§1). 2. **OpenSSF PRs C/D/E** — land in parallel, they don't conflict with the helper roadmap. -3. Open one issue per accepted helper in §1 with its source reference for traceability. From 6b70a8d7afcf25c5548a493c95495886035cb09b Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:22:07 +0000 Subject: [PATCH 19/34] =?UTF-8?q?feat(array):=20=E2=9C=A8=20add=20isEmpty?= =?UTF-8?q?=20and=20isNonEmpty=20helpers=20with=20tests=20and=20examples?= =?UTF-8?q?=20-=20implement=20isEmpty=20for=20arrays=20-=20implement=20isN?= =?UTF-8?q?onEmpty=20for=20arrays=20-=20add=20tests=20for=20both=20helpers?= =?UTF-8?q?=20-=20add=20examples=20for=20usage=20feat(object):=20=E2=9C=A8?= =?UTF-8?q?=20add=20isEmpty=20and=20isNonEmpty=20helpers=20with=20tests=20?= =?UTF-8?q?and=20examples=20-=20implement=20isEmpty=20for=20objects=20-=20?= =?UTF-8?q?implement=20isNonEmpty=20for=20objects=20-=20add=20tests=20for?= =?UTF-8?q?=20both=20helpers=20-=20add=20examples=20for=20usage=20feat(str?= =?UTF-8?q?ing):=20=E2=9C=A8=20add=20isEmpty=20and=20isNonEmpty=20helpers?= =?UTF-8?q?=20with=20tests=20and=20examples=20-=20implement=20isEmpty=20fo?= =?UTF-8?q?r=20strings=20-=20implement=20isNonEmpty=20for=20strings=20-=20?= =?UTF-8?q?add=20tests=20for=20both=20helpers=20-=20add=20examples=20for?= =?UTF-8?q?=20usage=20refactor(type):=20=E2=99=BB=EF=B8=8F=20deprecate=20m?= =?UTF-8?q?onolithic=20isEmpty=20helper=20-=20suggest=20category-specific?= =?UTF-8?q?=20helpers=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 31 ++---------------- helpers/array/isEmpty.example.ts | 47 ++++++++++++++++++++++++++++ helpers/array/isEmpty.spec.ts | 30 ++++++++++++++++++ helpers/array/isEmpty.test.ts | 43 +++++++++++++++++++++++++ helpers/array/isEmpty.ts | 18 +++++++++++ helpers/array/isNonEmpty.example.ts | 47 ++++++++++++++++++++++++++++ helpers/array/isNonEmpty.spec.ts | 35 +++++++++++++++++++++ helpers/array/isNonEmpty.test.ts | 43 +++++++++++++++++++++++++ helpers/array/isNonEmpty.ts | 18 +++++++++++ helpers/object/isEmpty.example.ts | 41 ++++++++++++++++++++++++ helpers/object/isEmpty.spec.ts | 41 ++++++++++++++++++++++++ helpers/object/isEmpty.test.ts | 37 ++++++++++++++++++++++ helpers/object/isEmpty.ts | 23 ++++++++++++++ helpers/object/isNonEmpty.example.ts | 46 +++++++++++++++++++++++++++ helpers/object/isNonEmpty.spec.ts | 36 +++++++++++++++++++++ helpers/object/isNonEmpty.test.ts | 33 +++++++++++++++++++ helpers/object/isNonEmpty.ts | 22 +++++++++++++ helpers/string/isEmpty.example.ts | 41 ++++++++++++++++++++++++ helpers/string/isEmpty.spec.ts | 38 ++++++++++++++++++++++ helpers/string/isEmpty.test.ts | 34 ++++++++++++++++++++ helpers/string/isEmpty.ts | 23 ++++++++++++++ helpers/string/isNonEmpty.example.ts | 41 ++++++++++++++++++++++++ helpers/string/isNonEmpty.spec.ts | 33 +++++++++++++++++++ helpers/string/isNonEmpty.test.ts | 25 +++++++++++++++ helpers/string/isNonEmpty.ts | 23 ++++++++++++++ helpers/type/isEmpty.ts | 2 ++ 26 files changed, 823 insertions(+), 28 deletions(-) create mode 100644 helpers/array/isEmpty.example.ts create mode 100644 helpers/array/isEmpty.spec.ts create mode 100644 helpers/array/isEmpty.test.ts create mode 100644 helpers/array/isEmpty.ts create mode 100644 helpers/array/isNonEmpty.example.ts create mode 100644 helpers/array/isNonEmpty.spec.ts create mode 100644 helpers/array/isNonEmpty.test.ts create mode 100644 helpers/array/isNonEmpty.ts create mode 100644 helpers/object/isEmpty.example.ts create mode 100644 helpers/object/isEmpty.spec.ts create mode 100644 helpers/object/isEmpty.test.ts create mode 100644 helpers/object/isEmpty.ts create mode 100644 helpers/object/isNonEmpty.example.ts create mode 100644 helpers/object/isNonEmpty.spec.ts create mode 100644 helpers/object/isNonEmpty.test.ts create mode 100644 helpers/object/isNonEmpty.ts create mode 100644 helpers/string/isEmpty.example.ts create mode 100644 helpers/string/isEmpty.spec.ts create mode 100644 helpers/string/isEmpty.test.ts create mode 100644 helpers/string/isEmpty.ts create mode 100644 helpers/string/isNonEmpty.example.ts create mode 100644 helpers/string/isNonEmpty.spec.ts create mode 100644 helpers/string/isNonEmpty.test.ts create mode 100644 helpers/string/isNonEmpty.ts diff --git a/TODO.md b/TODO.md index 43617b9c..46d1bbef 100644 --- a/TODO.md +++ b/TODO.md @@ -6,31 +6,7 @@ Legend: 🔴 High priority · 🟡 Medium · 🟢 Low --- -## 1. `isEmpty` — split by category - -**Current state:** `type/isEmpty` is a monolithic helper covering string, array, Map, Set, and plain -objects in a single function. - -**Decision:** `isEmpty` checks *state*, not *type* — it does not belong in `type/`. Each category -should own its focused predicate. See the **Helper Placement** rule in `AGENTS.md`. - -**Planned helpers:** - -- **`isEmpty`** (`array/`) — `Array.isArray(value) && value.length === 0` -- **`isEmpty`** (`string/`) — `value === ''` -- **`isEmpty`** (`object/`) — `isPlainObject(value) && Object.keys(value).length === 0` -- **`isNonEmpty`** (`array/`) — inverse; complements `isNonEmptyArray` currently in `type/` -- **`isNonEmpty`** (`object/`) — inverse - -**Open questions before implementation:** - -- [ ] Deprecate or keep `type/isEmpty`? (currently the only multi-type predicate — exception to the rule) -- [ ] Should `isNonEmptyArray` and `isNonEmptyString` (currently in `type/`) move to `array/` and `string/`? -- [ ] Naming: `object/isNonEmpty` vs `object/isNonEmptyObject`? - ---- - -## 2. OpenSSF Scorecard +## 1. OpenSSF Scorecard > Last snapshot: **6.7**. Goal: lift the score by closing the highest-impact > checks first, while keeping CI behaviour stable. @@ -89,7 +65,6 @@ After each PR, re-run Scorecard and capture the delta. --- -## 3. Suggested next steps +## 2. Suggested next steps -1. **`isEmpty` split** — create `array/isEmpty`, `string/isEmpty`, `object/isEmpty`; resolve open questions (§1). -2. **OpenSSF PRs C/D/E** — land in parallel, they don't conflict with the helper roadmap. +1. **OpenSSF PRs C/D/E** — land in parallel with the helper roadmap. diff --git a/helpers/array/isEmpty.example.ts b/helpers/array/isEmpty.example.ts new file mode 100644 index 00000000..351cbc0a --- /dev/null +++ b/helpers/array/isEmpty.example.ts @@ -0,0 +1,47 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isEmpty } from './isEmpty'; + +const examples: HelperExamples = { + helper: 'isEmpty', + category: 'array', + examples: [ + { + title: 'Check if an array is empty', + description: 'Returns true only for arrays with no elements.', + code: `isEmpty([]) // => true +isEmpty([1, 2, 3]) // => false +isEmpty([null]) // => false (null is still an element)`, + assert: () => { + if (!isEmpty([])) throw new Error('[] should be empty'); + if (isEmpty([1, 2, 3])) throw new Error('[1,2,3] should not be empty'); + if (isEmpty([null])) throw new Error('[null] should not be empty'); + }, + }, + { + title: 'Branch on empty array with type narrowing', + description: 'In the true branch, the type narrows to never[], ensuring no element access.', + code: `function first(arr: T[]): T | undefined { + if (isEmpty(arr)) return undefined; + return arr[0]; // TypeScript knows arr is non-empty here +} +first([]) // => undefined +first([1, 2]) // => 1`, + assert: () => { + function first(arr: T[]): T | undefined { + if (isEmpty(arr)) return undefined; + return arr[0]; + } + if (first([]) !== undefined) throw new Error('Expected undefined'); + if (first([1, 2]) !== 1) throw new Error('Expected 1'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/array/isEmpty.spec.ts b/helpers/array/isEmpty.spec.ts new file mode 100644 index 00000000..d4ec6eb2 --- /dev/null +++ b/helpers/array/isEmpty.spec.ts @@ -0,0 +1,30 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty — property-based', () => { + it('is always false for arrays with at least one element', () => { + fc.assert( + fc.property(fc.array(fc.anything(), { minLength: 1 }), (arr) => { + expect(isEmpty(arr)).toBe(false); + }), + ); + }); +}); + +describe('isEmpty — contracts', () => { + it('isEmpty and isNonEmpty are logical inverses', async () => { + const { isNonEmpty } = await import('./isNonEmpty'); + fc.assert( + fc.property(fc.array(fc.anything()), (arr) => { + expect(isEmpty(arr)).toBe(!isNonEmpty(arr)); + }), + ); + }); +}); diff --git a/helpers/array/isEmpty.test.ts b/helpers/array/isEmpty.test.ts new file mode 100644 index 00000000..e616916d --- /dev/null +++ b/helpers/array/isEmpty.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty', () => { + it('should return true for an empty array', () => { + expect(isEmpty([])).toBe(true); + }); + + it('should return false for a non-empty array', () => { + expect(isEmpty([1])).toBe(false); + }); + + it('should return false for an array with multiple elements', () => { + expect(isEmpty([1, 2, 3])).toBe(false); + }); + + it('should return false for an array containing falsy values', () => { + expect(isEmpty([null])).toBe(false); + expect(isEmpty([undefined])).toBe(false); + expect(isEmpty([0])).toBe(false); + expect(isEmpty([''])).toBe(false); + expect(isEmpty([false])).toBe(false); + }); + + it('should work with readonly arrays', () => { + const arr: readonly number[] = []; + expect(isEmpty(arr)).toBe(true); + }); + + it('should narrow type to never[] in true branch', () => { + const arr: string[] = []; + if (isEmpty(arr)) { + const _: readonly never[] = arr; + expect(_).toEqual([]); + } + }); +}); diff --git a/helpers/array/isEmpty.ts b/helpers/array/isEmpty.ts new file mode 100644 index 00000000..9db8669c --- /dev/null +++ b/helpers/array/isEmpty.ts @@ -0,0 +1,18 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if an array is empty (has no elements). + * @param value - The array to check + * @returns `true` if the array has no elements + * @example + * isEmpty([]) // => true + * isEmpty([1, 2, 3]) // => false + * @since next + */ +export function isEmpty(value: readonly unknown[]): value is readonly never[] { + return value.length === 0; +} diff --git a/helpers/array/isNonEmpty.example.ts b/helpers/array/isNonEmpty.example.ts new file mode 100644 index 00000000..bb3a4bb5 --- /dev/null +++ b/helpers/array/isNonEmpty.example.ts @@ -0,0 +1,47 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNonEmpty } from './isNonEmpty'; + +const examples: HelperExamples = { + helper: 'isNonEmpty', + category: 'array', + examples: [ + { + title: 'Check if an array has elements', + description: 'Returns true for arrays with at least one element, regardless of the element values.', + code: `isNonEmpty([1, 2, 3]) // => true +isNonEmpty([null]) // => true (null is still an element) +isNonEmpty([]) // => false`, + assert: () => { + if (!isNonEmpty([1, 2, 3])) throw new Error('[1,2,3] should be non-empty'); + if (!isNonEmpty([null])) throw new Error('[null] should be non-empty'); + if (isNonEmpty([])) throw new Error('[] should not be non-empty'); + }, + }, + { + title: 'Safe first-element access with type narrowing', + description: 'In the true branch, the type narrows to [T, ...T[]], making arr[0] always defined.', + code: `function first(arr: readonly T[]): T | undefined { + if (isNonEmpty(arr)) return arr[0]; // arr[0] is T, not T | undefined + return undefined; +} +first([1, 2]) // => 1 +first([]) // => undefined`, + assert: () => { + function first(arr: readonly T[]): T | undefined { + if (isNonEmpty(arr)) return arr[0]; + return undefined; + } + if (first([1, 2]) !== 1) throw new Error('Expected 1'); + if (first([]) !== undefined) throw new Error('Expected undefined'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/array/isNonEmpty.spec.ts b/helpers/array/isNonEmpty.spec.ts new file mode 100644 index 00000000..1b9a049b --- /dev/null +++ b/helpers/array/isNonEmpty.spec.ts @@ -0,0 +1,35 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty — property-based', () => { + it('is always true for arrays with at least one element', () => { + fc.assert( + fc.property(fc.array(fc.anything(), { minLength: 1 }), (arr) => { + expect(isNonEmpty(arr)).toBe(true); + }), + ); + }); + + it('is always false for empty arrays', () => { + expect(isNonEmpty([])).toBe(false); + }); +}); + +describe('isNonEmpty — contracts', () => { + it('first element is accessible without undefined when isNonEmpty is true', () => { + fc.assert( + fc.property(fc.array(fc.integer(), { minLength: 1 }), (arr) => { + if (isNonEmpty(arr)) { + expect(arr[0]).toBeDefined(); + } + }), + ); + }); +}); diff --git a/helpers/array/isNonEmpty.test.ts b/helpers/array/isNonEmpty.test.ts new file mode 100644 index 00000000..78aa3879 --- /dev/null +++ b/helpers/array/isNonEmpty.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty', () => { + it('should return true for a non-empty array', () => { + expect(isNonEmpty([1])).toBe(true); + }); + + it('should return true for an array with multiple elements', () => { + expect(isNonEmpty([1, 2, 3])).toBe(true); + }); + + it('should return false for an empty array', () => { + expect(isNonEmpty([])).toBe(false); + }); + + it('should return true for an array containing falsy values', () => { + expect(isNonEmpty([null])).toBe(true); + expect(isNonEmpty([undefined])).toBe(true); + expect(isNonEmpty([0])).toBe(true); + expect(isNonEmpty([''])).toBe(true); + expect(isNonEmpty([false])).toBe(true); + }); + + it('should work with readonly arrays', () => { + const arr: readonly number[] = [1, 2]; + expect(isNonEmpty(arr)).toBe(true); + }); + + it('should narrow type to non-empty tuple in true branch', () => { + const arr: number[] = [1, 2, 3]; + if (isNonEmpty(arr)) { + const first: number = arr[0]; + expect(first).toBe(1); + } + }); +}); diff --git a/helpers/array/isNonEmpty.ts b/helpers/array/isNonEmpty.ts new file mode 100644 index 00000000..3ad12e80 --- /dev/null +++ b/helpers/array/isNonEmpty.ts @@ -0,0 +1,18 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if an array is non-empty (has at least one element). + * @param value - The array to check + * @returns `true` if the array has at least one element + * @example + * isNonEmpty([1, 2, 3]) // => true + * isNonEmpty([]) // => false + * @since next + */ +export function isNonEmpty(value: readonly T[]): value is readonly [T, ...T[]] { + return value.length > 0; +} diff --git a/helpers/object/isEmpty.example.ts b/helpers/object/isEmpty.example.ts new file mode 100644 index 00000000..5508c61e --- /dev/null +++ b/helpers/object/isEmpty.example.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isEmpty } from './isEmpty'; + +const examples: HelperExamples = { + helper: 'isEmpty', + category: 'object', + examples: [ + { + title: 'Check if an object has no own string-keyed properties', + description: 'Returns true for `{}`. Symbol-keyed properties are not counted.', + code: `isEmpty({}) // => true +isEmpty({ a: 1 }) // => false +isEmpty({ a: undefined }) // => false (key exists even if value is undefined)`, + assert: () => { + if (!isEmpty({})) throw new Error('{} should be empty'); + if (isEmpty({ a: 1 })) throw new Error('{a:1} should not be empty'); + if (isEmpty({ a: undefined })) throw new Error('{a:undefined} should not be empty'); + }, + }, + { + title: 'Symbol keys are not counted', + description: 'An object with only symbol-keyed properties is considered empty.', + code: `const sym = Symbol('x'); +const obj = { [sym]: 1 }; +isEmpty(obj) // => true (only string keys are counted)`, + assert: () => { + const sym = Symbol('x'); + const obj: Record = { [sym]: 1 }; + if (!isEmpty(obj)) throw new Error('Object with only symbol key should be empty'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/object/isEmpty.spec.ts b/helpers/object/isEmpty.spec.ts new file mode 100644 index 00000000..dcd1cd0d --- /dev/null +++ b/helpers/object/isEmpty.spec.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty — property-based', () => { + it('is always false for objects with at least one string key', () => { + fc.assert( + fc.property( + fc.record({ key: fc.string() }, { requiredKeys: ['key'] }), + (obj) => { + expect(isEmpty(obj)).toBe(false); + }, + ), + ); + }); +}); + +describe('isEmpty — contracts', () => { + it('isEmpty and isNonEmpty are logical inverses', async () => { + const { isNonEmpty } = await import('./isNonEmpty'); + fc.assert( + fc.property(fc.dictionary(fc.string(), fc.anything()), (obj) => { + expect(isEmpty(obj)).toBe(!isNonEmpty(obj)); + }), + ); + }); + + it('is equivalent to Object.keys(value).length === 0', () => { + fc.assert( + fc.property(fc.dictionary(fc.string(), fc.anything()), (obj) => { + expect(isEmpty(obj)).toBe(Object.keys(obj).length === 0); + }), + ); + }); +}); diff --git a/helpers/object/isEmpty.test.ts b/helpers/object/isEmpty.test.ts new file mode 100644 index 00000000..eb33b08c --- /dev/null +++ b/helpers/object/isEmpty.test.ts @@ -0,0 +1,37 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty', () => { + it('should return true for an empty object', () => { + expect(isEmpty({})).toBe(true); + }); + + it('should return false for an object with string keys', () => { + expect(isEmpty({ a: 1 })).toBe(false); + expect(isEmpty({ a: undefined })).toBe(false); + }); + + it('should not count symbol-keyed properties', () => { + const sym = Symbol('x'); + const obj: Record = {}; + obj[sym] = 1; + expect(isEmpty(obj)).toBe(true); + }); + + it('should return false for an object with multiple keys', () => { + expect(isEmpty({ a: 1, b: 2, c: 3 })).toBe(false); + }); + + it('should handle null-prototype objects', () => { + const obj = Object.create(null) as Record; + expect(isEmpty(obj)).toBe(true); + obj['key'] = 'value'; + expect(isEmpty(obj)).toBe(false); + }); +}); diff --git a/helpers/object/isEmpty.ts b/helpers/object/isEmpty.ts new file mode 100644 index 00000000..07436921 --- /dev/null +++ b/helpers/object/isEmpty.ts @@ -0,0 +1,23 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a plain object has no own enumerable string-keyed properties. + * + * Symbol-keyed properties are not counted. Use `Object.getOwnPropertySymbols` + * separately if symbol keys matter for your use case. + * + * @param value - The object to check + * @returns `true` if the object has no own enumerable string-keyed properties + * @example + * isEmpty({}) // => true + * isEmpty({ a: 1 }) // => false + * isEmpty({ a: undefined }) // => false (key exists) + * @since next + */ +export function isEmpty(value: Record): boolean { + return Object.keys(value).length === 0; +} diff --git a/helpers/object/isNonEmpty.example.ts b/helpers/object/isNonEmpty.example.ts new file mode 100644 index 00000000..66249fe4 --- /dev/null +++ b/helpers/object/isNonEmpty.example.ts @@ -0,0 +1,46 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNonEmpty } from './isNonEmpty'; + +const examples: HelperExamples = { + helper: 'isNonEmpty', + category: 'object', + examples: [ + { + title: 'Check if an object has own string-keyed properties', + description: 'Returns true when at least one own enumerable string key is present.', + code: `isNonEmpty({ a: 1 }) // => true +isNonEmpty({ a: undefined }) // => true (key exists) +isNonEmpty({}) // => false`, + assert: () => { + if (!isNonEmpty({ a: 1 })) throw new Error('{a:1} should be non-empty'); + if (!isNonEmpty({ a: undefined })) throw new Error('{a:undefined} should be non-empty'); + if (isNonEmpty({})) throw new Error('{} should not be non-empty'); + }, + }, + { + title: 'Guard before iterating object keys', + description: 'Use isNonEmpty before looping to avoid processing empty objects.', + code: `function processConfig(config: Record): void { + if (!isNonEmpty(config)) { + console.warn('Config is empty'); + return; + } + for (const key of Object.keys(config)) { + // process each key + } +}`, + assert: () => { + if (isNonEmpty({})) throw new Error('Empty object should fail guard'); + if (!isNonEmpty({ x: 1 })) throw new Error('Non-empty object should pass guard'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/object/isNonEmpty.spec.ts b/helpers/object/isNonEmpty.spec.ts new file mode 100644 index 00000000..a30e968e --- /dev/null +++ b/helpers/object/isNonEmpty.spec.ts @@ -0,0 +1,36 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty — property-based', () => { + it('is always true for objects with at least one string key', () => { + fc.assert( + fc.property( + fc.record({ key: fc.string() }, { requiredKeys: ['key'] }), + (obj) => { + expect(isNonEmpty(obj)).toBe(true); + }, + ), + ); + }); + + it('is always false for objects with no string keys', () => { + expect(isNonEmpty({})).toBe(false); + }); +}); + +describe('isNonEmpty — contracts', () => { + it('is equivalent to Object.keys(value).length > 0', () => { + fc.assert( + fc.property(fc.dictionary(fc.string(), fc.anything()), (obj) => { + expect(isNonEmpty(obj)).toBe(Object.keys(obj).length > 0); + }), + ); + }); +}); diff --git a/helpers/object/isNonEmpty.test.ts b/helpers/object/isNonEmpty.test.ts new file mode 100644 index 00000000..6261f593 --- /dev/null +++ b/helpers/object/isNonEmpty.test.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty', () => { + it('should return true for an object with string keys', () => { + expect(isNonEmpty({ a: 1 })).toBe(true); + expect(isNonEmpty({ a: undefined })).toBe(true); + }); + + it('should return false for an empty object', () => { + expect(isNonEmpty({})).toBe(false); + }); + + it('should not count symbol-keyed properties', () => { + const sym = Symbol('x'); + const obj: Record = {}; + obj[sym] = 1; + expect(isNonEmpty(obj)).toBe(false); + }); + + it('should handle null-prototype objects', () => { + const obj = Object.create(null) as Record; + expect(isNonEmpty(obj)).toBe(false); + obj['key'] = 'value'; + expect(isNonEmpty(obj)).toBe(true); + }); +}); diff --git a/helpers/object/isNonEmpty.ts b/helpers/object/isNonEmpty.ts new file mode 100644 index 00000000..e0b3bac8 --- /dev/null +++ b/helpers/object/isNonEmpty.ts @@ -0,0 +1,22 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a plain object has at least one own enumerable string-keyed property. + * + * Symbol-keyed properties are not counted. Use `Object.getOwnPropertySymbols` + * separately if symbol keys matter for your use case. + * + * @param value - The object to check + * @returns `true` if the object has at least one own enumerable string-keyed property + * @example + * isNonEmpty({ a: 1 }) // => true + * isNonEmpty({}) // => false + * @since next + */ +export function isNonEmpty(value: Record): boolean { + return Object.keys(value).length > 0; +} diff --git a/helpers/string/isEmpty.example.ts b/helpers/string/isEmpty.example.ts new file mode 100644 index 00000000..6ef3a5eb --- /dev/null +++ b/helpers/string/isEmpty.example.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isEmpty } from './isEmpty'; + +const examples: HelperExamples = { + helper: 'isEmpty', + category: 'string', + examples: [ + { + title: 'Check if a string is empty', + description: 'Returns true only for `""`. Whitespace-only strings are not considered empty.', + code: `isEmpty('') // => true +isEmpty(' ') // => false (whitespace is content) +isEmpty('foo') // => false`, + assert: () => { + if (!isEmpty('')) throw new Error('"" should be empty'); + if (isEmpty(' ')) throw new Error('" " should not be empty'); + if (isEmpty('foo')) throw new Error('"foo" should not be empty'); + }, + }, + { + title: 'Treat blank strings as empty by trimming first', + description: 'Compose with .trim() when whitespace-only should also be considered empty.', + code: `isEmpty(''.trim()) // => true +isEmpty(' '.trim()) // => true +isEmpty('hi'.trim()) // => false`, + assert: () => { + if (!isEmpty(''.trim())) throw new Error('Expected true'); + if (!isEmpty(' '.trim())) throw new Error('Expected true after trim'); + if (isEmpty('hi'.trim())) throw new Error('Expected false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/string/isEmpty.spec.ts b/helpers/string/isEmpty.spec.ts new file mode 100644 index 00000000..1403aecc --- /dev/null +++ b/helpers/string/isEmpty.spec.ts @@ -0,0 +1,38 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty — property-based', () => { + it('is always false for non-empty strings', () => { + fc.assert( + fc.property(fc.string({ minLength: 1 }), (s) => { + expect(isEmpty(s)).toBe(false); + }), + ); + }); +}); + +describe('isEmpty — contracts', () => { + it('isEmpty and isNonEmpty are logical inverses', async () => { + const { isNonEmpty } = await import('./isNonEmpty'); + fc.assert( + fc.property(fc.string(), (s) => { + expect(isEmpty(s)).toBe(!isNonEmpty(s)); + }), + ); + }); + + it('is equivalent to s === ""', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(isEmpty(s)).toBe(s === ''); + }), + ); + }); +}); diff --git a/helpers/string/isEmpty.test.ts b/helpers/string/isEmpty.test.ts new file mode 100644 index 00000000..866c64af --- /dev/null +++ b/helpers/string/isEmpty.test.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty', () => { + it('should return true for an empty string', () => { + expect(isEmpty('')).toBe(true); + }); + + it('should return false for a whitespace-only string', () => { + expect(isEmpty(' ')).toBe(false); + expect(isEmpty(' ')).toBe(false); + expect(isEmpty('\t')).toBe(false); + expect(isEmpty('\n')).toBe(false); + }); + + it('should return false for a non-empty string', () => { + expect(isEmpty('a')).toBe(false); + expect(isEmpty('hello')).toBe(false); + }); + + it('should narrow type to empty string literal in true branch', () => { + const s: string = ''; + if (isEmpty(s)) { + const _: '' = s; + expect(_).toBe(''); + } + }); +}); diff --git a/helpers/string/isEmpty.ts b/helpers/string/isEmpty.ts new file mode 100644 index 00000000..d45fa88d --- /dev/null +++ b/helpers/string/isEmpty.ts @@ -0,0 +1,23 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a string is empty (`""`). + * + * This is a strict emptiness check — whitespace-only strings are **not** considered + * empty. Use `isEmpty(value.trim())` if you need to treat blank strings as empty. + * + * @param value - The string to check + * @returns `true` if the string is `""` + * @example + * isEmpty('') // => true + * isEmpty(' ') // => false (whitespace-only, not empty) + * isEmpty('foo') // => false + * @since next + */ +export function isEmpty(value: string): value is '' { + return value === ''; +} diff --git a/helpers/string/isNonEmpty.example.ts b/helpers/string/isNonEmpty.example.ts new file mode 100644 index 00000000..121c5a00 --- /dev/null +++ b/helpers/string/isNonEmpty.example.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNonEmpty } from './isNonEmpty'; + +const examples: HelperExamples = { + helper: 'isNonEmpty', + category: 'string', + examples: [ + { + title: 'Check if a string has content', + description: 'Returns true for any string with at least one character, including whitespace.', + code: `isNonEmpty('hello') // => true +isNonEmpty(' ') // => true (whitespace is content) +isNonEmpty('') // => false`, + assert: () => { + if (!isNonEmpty('hello')) throw new Error('"hello" should be non-empty'); + if (!isNonEmpty(' ')) throw new Error('" " should be non-empty'); + if (isNonEmpty('')) throw new Error('"" should not be non-empty'); + }, + }, + { + title: 'Exclude blank strings by trimming first', + description: 'Compose with .trim() when whitespace-only strings should be treated as empty.', + code: `isNonEmpty('hello'.trim()) // => true +isNonEmpty(' '.trim()) // => false +isNonEmpty(''.trim()) // => false`, + assert: () => { + if (!isNonEmpty('hello'.trim())) throw new Error('Expected true'); + if (isNonEmpty(' '.trim())) throw new Error('Expected false after trim'); + if (isNonEmpty(''.trim())) throw new Error('Expected false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/string/isNonEmpty.spec.ts b/helpers/string/isNonEmpty.spec.ts new file mode 100644 index 00000000..143c04a0 --- /dev/null +++ b/helpers/string/isNonEmpty.spec.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty — property-based', () => { + it('is always true for strings with at least one character', () => { + fc.assert( + fc.property(fc.string({ minLength: 1 }), (s) => { + expect(isNonEmpty(s)).toBe(true); + }), + ); + }); + + it('is always false for the empty string', () => { + expect(isNonEmpty('')).toBe(false); + }); +}); + +describe('isNonEmpty — contracts', () => { + it('is equivalent to s.length > 0', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(isNonEmpty(s)).toBe(s.length > 0); + }), + ); + }); +}); diff --git a/helpers/string/isNonEmpty.test.ts b/helpers/string/isNonEmpty.test.ts new file mode 100644 index 00000000..250bb750 --- /dev/null +++ b/helpers/string/isNonEmpty.test.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty', () => { + it('should return true for a non-empty string', () => { + expect(isNonEmpty('a')).toBe(true); + expect(isNonEmpty('hello')).toBe(true); + }); + + it('should return true for a whitespace-only string', () => { + expect(isNonEmpty(' ')).toBe(true); + expect(isNonEmpty(' ')).toBe(true); + expect(isNonEmpty('\t')).toBe(true); + }); + + it('should return false for an empty string', () => { + expect(isNonEmpty('')).toBe(false); + }); +}); diff --git a/helpers/string/isNonEmpty.ts b/helpers/string/isNonEmpty.ts new file mode 100644 index 00000000..0454ab5c --- /dev/null +++ b/helpers/string/isNonEmpty.ts @@ -0,0 +1,23 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a string is non-empty (has at least one character). + * + * Whitespace-only strings are considered non-empty. + * Use `isNonEmpty(value.trim())` if you need to exclude blank strings. + * + * @param value - The string to check + * @returns `true` if the string has at least one character + * @example + * isNonEmpty('hello') // => true + * isNonEmpty(' ') // => true (whitespace-only, not empty) + * isNonEmpty('') // => false + * @since next + */ +export function isNonEmpty(value: string): boolean { + return value.length > 0; +} diff --git a/helpers/type/isEmpty.ts b/helpers/type/isEmpty.ts index 8f856ec0..628e4e80 100644 --- a/helpers/type/isEmpty.ts +++ b/helpers/type/isEmpty.ts @@ -39,6 +39,8 @@ import { isSpecialObject } from './isSpecialObject'; * declare const m: ReadonlyMap | null; * if (isEmpty(m)) { ... } // m: ReadonlyMap | null * @since 2.0.0 + * @deprecated Use the category-specific helper instead: `array/isEmpty`, `string/isEmpty`, + * or `object/isEmpty`. This monolithic helper will be removed in v3. */ export function isEmpty(value: unknown): value is null | undefined | '' | never[] | ReadonlyMap | ReadonlySet { if (value === null || value === undefined) { From 0ba8807bab76fef4680d47d193f4090c0cad97b6 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:52:19 +0000 Subject: [PATCH 20/34] =?UTF-8?q?feat(string):=20=E2=9C=A8=20add=20isBlank?= =?UTF-8?q?=20function=20with=20examples=20and=20tests=20-=20implement=20i?= =?UTF-8?q?sBlank=20to=20check=20for=20empty=20or=20whitespace-only=20stri?= =?UTF-8?q?ngs=20-=20add=20property-based=20tests=20for=20isBlank=20-=20cr?= =?UTF-8?q?eate=20unit=20tests=20for=20isBlank=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/string/isBlank.example.ts | 55 +++++++++++++++++++++++++++ helpers/string/isBlank.spec.ts | 62 +++++++++++++++++++++++++++++++ helpers/string/isBlank.test.ts | 49 ++++++++++++++++++++++++ helpers/string/isBlank.ts | 33 ++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 helpers/string/isBlank.example.ts create mode 100644 helpers/string/isBlank.spec.ts create mode 100644 helpers/string/isBlank.test.ts create mode 100644 helpers/string/isBlank.ts diff --git a/helpers/string/isBlank.example.ts b/helpers/string/isBlank.example.ts new file mode 100644 index 00000000..ea3c06b6 --- /dev/null +++ b/helpers/string/isBlank.example.ts @@ -0,0 +1,55 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isBlank } from './isBlank'; + +const examples: HelperExamples = { + helper: 'isBlank', + category: 'string', + examples: [ + { + title: 'Detect empty or whitespace-only strings', + description: + 'Returns true for "" and for any string made entirely of whitespace — including ' + + 'non-breaking space (U+00A0), en/em spaces, ideographic space, and BOM.', + code: `isBlank('') // => true +isBlank(' ') // => true +isBlank('\\t\\n') // => true +isBlank(' ') // => true (non-breaking space U+00A0) +isBlank('foo') // => false +isBlank(' x ') // => false`, + assert: () => { + if (!isBlank('')) throw new Error('Expected true for ""'); + if (!isBlank(' ')) throw new Error('Expected true for spaces'); + if (!isBlank(' ')) throw new Error('Expected true for NBSP'); + if (isBlank('foo')) throw new Error('Expected false for "foo"'); + }, + }, + { + title: 'Form validation — reject blank input', + description: 'Use isBlank to reject fields that contain only whitespace.', + code: `function validateName(name: string): string | null { + if (isBlank(name)) return 'Name is required'; + return null; +} +validateName('') // => 'Name is required' +validateName(' ') // => 'Name is required' +validateName('Ada') // => null`, + assert: () => { + function validateName(name: string): string | null { + if (isBlank(name)) return 'Name is required'; + return null; + } + if (validateName('') === null) throw new Error('Expected error for ""'); + if (validateName(' ') === null) throw new Error('Expected error for spaces'); + if (validateName('Ada') !== null) throw new Error('Expected null for "Ada"'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/string/isBlank.spec.ts b/helpers/string/isBlank.spec.ts new file mode 100644 index 00000000..588d2410 --- /dev/null +++ b/helpers/string/isBlank.spec.ts @@ -0,0 +1,62 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isBlank } from './isBlank'; + +describe('isBlank — property-based', () => { + it('is always false for strings containing at least one non-whitespace character', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + (s) => { + expect(isBlank(s)).toBe(false); + }, + ), + ); + }); + + it('is always true for strings of only ASCII whitespace', () => { + const whitespaceChars = [' ', '\t', '\n', '\r', '\f', '\v']; + fc.assert( + fc.property( + fc.array(fc.constantFrom(...whitespaceChars), { minLength: 1 }).map((a) => a.join('')), + (s) => { + expect(isBlank(s)).toBe(true); + }, + ), + ); + }); +}); + +describe('isBlank — contracts', () => { + it('isBlank and isNotBlank are logical inverses', async () => { + const { isNotBlank } = await import('./isNotBlank'); + fc.assert( + fc.property(fc.string(), (s) => { + expect(isBlank(s)).toBe(!isNotBlank(s)); + }), + ); + }); + + it('is equivalent to value.trim() === ""', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(isBlank(s)).toBe(s.trim() === ''); + }), + ); + }); + + it('isBlank implies isEmpty', async () => { + const { isEmpty } = await import('./isEmpty'); + fc.assert( + fc.property(fc.string(), (s) => { + if (isEmpty(s)) expect(isBlank(s)).toBe(true); // empty ⊆ blank + }), + ); + }); +}); diff --git a/helpers/string/isBlank.test.ts b/helpers/string/isBlank.test.ts new file mode 100644 index 00000000..07b5038b --- /dev/null +++ b/helpers/string/isBlank.test.ts @@ -0,0 +1,49 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isBlank } from './isBlank'; + +describe('isBlank', () => { + it('should return true for an empty string', () => { + expect(isBlank('')).toBe(true); + }); + + it('should return true for ASCII whitespace-only strings', () => { + expect(isBlank(' ')).toBe(true); + expect(isBlank(' ')).toBe(true); + expect(isBlank('\t')).toBe(true); + expect(isBlank('\n')).toBe(true); + expect(isBlank('\r')).toBe(true); + expect(isBlank('\f')).toBe(true); + expect(isBlank('\v')).toBe(true); + expect(isBlank(' \t\n\r ')).toBe(true); + }); + + it('should return true for Unicode whitespace-only strings', () => { + expect(isBlank(' ')).toBe(true); // non-breaking space + expect(isBlank(' ')).toBe(true); // en space + expect(isBlank(' ')).toBe(true); // em space + expect(isBlank(' ')).toBe(true); // thin space + expect(isBlank(' ')).toBe(true); // hair space + expect(isBlank(' ')).toBe(true); // narrow NBSP + expect(isBlank(' ')).toBe(true); // ideographic space + expect(isBlank('')).toBe(true); // BOM / ZWNBSP + }); + + it('should return false for strings with visible content', () => { + expect(isBlank('foo')).toBe(false); + expect(isBlank(' x ')).toBe(false); + expect(isBlank('0')).toBe(false); + }); + + it('should not treat zero-width characters as whitespace', () => { + expect(isBlank('​')).toBe(false); // zero-width space + expect(isBlank('‌')).toBe(false); // zero-width non-joiner + expect(isBlank('‍')).toBe(false); // zero-width joiner + expect(isBlank('⁠')).toBe(false); // word joiner + }); +}); diff --git a/helpers/string/isBlank.ts b/helpers/string/isBlank.ts new file mode 100644 index 00000000..955d5ca0 --- /dev/null +++ b/helpers/string/isBlank.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a string is blank — empty or contains only whitespace characters. + * + * Uses `String.prototype.trim()` internally, which covers all ECMAScript + * whitespace: standard ASCII whitespace (`\t`, `\n`, `\r`, `\f`, `\v`), + * non-breaking space (U+00A0), BOM (U+FEFF), and all Unicode "Space_Separator" + * category characters (en space, em space, thin space, ideographic space, etc.). + * + * **Zero-width characters** (U+200B zero-width space, U+200C, U+200D, U+2060) + * are **not** treated as whitespace — they are Unicode "Format" (Cf) characters, + * not spaces. Strip them explicitly if needed: + * `isBlank(value.replace(/[​-‍⁠]/g, ''))` + * + * @param value - The string to check + * @returns `true` if the string is empty or contains only whitespace + * @example + * isBlank('') // => true + * isBlank(' ') // => true + * isBlank('\t\n') // => true + * isBlank(' ') // => true (non-breaking space U+00A0) + * isBlank('foo') // => false + * isBlank(' x ') // => false + * @since next + */ +export function isBlank(value: string): boolean { + return value.trim() === ''; +} From 3cd87a7879040d1c553e54203bbfb88feaa93f4f Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 20:53:34 +0000 Subject: [PATCH 21/34] =?UTF-8?q?feat(string):=20=E2=9C=A8=20add=20isNotBl?= =?UTF-8?q?ank=20function=20with=20examples=20and=20tests=20-=20implement?= =?UTF-8?q?=20isNotBlank=20to=20check=20for=20non-blank=20strings=20-=20ad?= =?UTF-8?q?d=20examples=20for=20usage=20-=20create=20property-based=20and?= =?UTF-8?q?=20contract=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/string/isNotBlank.example.ts | 45 ++++++++++++++++++++++++++++ helpers/string/isNotBlank.spec.ts | 32 ++++++++++++++++++++ helpers/string/isNotBlank.test.ts | 40 +++++++++++++++++++++++++ helpers/string/isNotBlank.ts | 27 +++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 helpers/string/isNotBlank.example.ts create mode 100644 helpers/string/isNotBlank.spec.ts create mode 100644 helpers/string/isNotBlank.test.ts create mode 100644 helpers/string/isNotBlank.ts diff --git a/helpers/string/isNotBlank.example.ts b/helpers/string/isNotBlank.example.ts new file mode 100644 index 00000000..b5ea7906 --- /dev/null +++ b/helpers/string/isNotBlank.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNotBlank } from './isNotBlank'; + +const examples: HelperExamples = { + helper: 'isNotBlank', + category: 'string', + examples: [ + { + title: 'Check that a string has real content', + description: + 'Returns true only when the string contains at least one non-whitespace character.', + code: `isNotBlank('foo') // => true +isNotBlank(' x ') // => true +isNotBlank('') // => false +isNotBlank(' ') // => false +isNotBlank('\\t') // => false`, + assert: () => { + if (!isNotBlank('foo')) throw new Error('Expected true for "foo"'); + if (!isNotBlank(' x ')) throw new Error('Expected true for " x "'); + if (isNotBlank('')) throw new Error('Expected false for ""'); + if (isNotBlank(' ')) throw new Error('Expected false for spaces'); + }, + }, + { + title: 'Filter out blank strings from an array', + description: 'Use as a predicate in .filter() to keep only strings with real content.', + code: `const tags = ['typescript', ' ', '', 'helpers']; +tags.filter(isNotBlank) +// => ['typescript', 'helpers']`, + assert: () => { + const tags = ['typescript', ' ', '', 'helpers']; + const result = tags.filter(isNotBlank); + if (result.length !== 2) throw new Error('Expected ["typescript", "helpers"]'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/string/isNotBlank.spec.ts b/helpers/string/isNotBlank.spec.ts new file mode 100644 index 00000000..53392e89 --- /dev/null +++ b/helpers/string/isNotBlank.spec.ts @@ -0,0 +1,32 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isNotBlank } from './isNotBlank'; + +describe('isNotBlank — property-based', () => { + it('is always true for strings containing at least one non-whitespace character', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + (s) => { + expect(isNotBlank(s)).toBe(true); + }, + ), + ); + }); +}); + +describe('isNotBlank — contracts', () => { + it('is equivalent to value.trim() !== ""', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(isNotBlank(s)).toBe(s.trim() !== ''); + }), + ); + }); +}); diff --git a/helpers/string/isNotBlank.test.ts b/helpers/string/isNotBlank.test.ts new file mode 100644 index 00000000..2e8f820f --- /dev/null +++ b/helpers/string/isNotBlank.test.ts @@ -0,0 +1,40 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNotBlank } from './isNotBlank'; + +describe('isNotBlank', () => { + it('should return true for strings with visible content', () => { + expect(isNotBlank('foo')).toBe(true); + expect(isNotBlank(' x ')).toBe(true); + expect(isNotBlank('0')).toBe(true); + }); + + it('should return true for strings containing zero-width characters', () => { + expect(isNotBlank('​')).toBe(true); // zero-width space — not whitespace + expect(isNotBlank('‌')).toBe(true); + expect(isNotBlank('‍')).toBe(true); + }); + + it('should return false for an empty string', () => { + expect(isNotBlank('')).toBe(false); + }); + + it('should return false for ASCII whitespace-only strings', () => { + expect(isNotBlank(' ')).toBe(false); + expect(isNotBlank('\t')).toBe(false); + expect(isNotBlank('\n')).toBe(false); + expect(isNotBlank(' \t\n\r ')).toBe(false); + }); + + it('should return false for Unicode whitespace-only strings', () => { + expect(isNotBlank(' ')).toBe(false); // non-breaking space + expect(isNotBlank(' ')).toBe(false); // em space + expect(isNotBlank(' ')).toBe(false); // ideographic space + expect(isNotBlank('')).toBe(false); // BOM + }); +}); diff --git a/helpers/string/isNotBlank.ts b/helpers/string/isNotBlank.ts new file mode 100644 index 00000000..a337adb4 --- /dev/null +++ b/helpers/string/isNotBlank.ts @@ -0,0 +1,27 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a string is not blank — non-empty and contains at least one + * non-whitespace character. + * + * Uses `String.prototype.trim()` internally. See `isBlank` for the full list + * of characters considered whitespace (includes non-breaking space, en/em space, + * ideographic space, etc.). + * + * @param value - The string to check + * @returns `true` if the string has at least one non-whitespace character + * @example + * isNotBlank('foo') // => true + * isNotBlank(' x ') // => true + * isNotBlank('') // => false + * isNotBlank(' ') // => false + * isNotBlank('\t\n') // => false + * @since next + */ +export function isNotBlank(value: string): boolean { + return value.trim() !== ''; +} From a2776545f0447ae8d6f0ea79ed90d36305e85a10 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 21:06:11 +0000 Subject: [PATCH 22/34] =?UTF-8?q?feat(array):=20=E2=9C=A8=20add=20select?= =?UTF-8?q?=20helper=20with=20examples=20and=20tests=20-=20implement=20sel?= =?UTF-8?q?ect=20function=20for=20filtering=20and=20transforming=20arrays?= =?UTF-8?q?=20-=20add=20examples=20for=20usage=20in=20select.example.ts=20?= =?UTF-8?q?-=20create=20property-based=20tests=20in=20select.spec.ts=20-?= =?UTF-8?q?=20add=20unit=20tests=20in=20select.test.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/array/select.example.ts | 53 ++++++++++++++++++++++ helpers/array/select.spec.ts | 80 +++++++++++++++++++++++++++++++++ helpers/array/select.test.ts | 52 +++++++++++++++++++++ helpers/array/select.ts | 45 +++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 helpers/array/select.example.ts create mode 100644 helpers/array/select.spec.ts create mode 100644 helpers/array/select.test.ts create mode 100644 helpers/array/select.ts diff --git a/helpers/array/select.example.ts b/helpers/array/select.example.ts new file mode 100644 index 00000000..be19c1d5 --- /dev/null +++ b/helpers/array/select.example.ts @@ -0,0 +1,53 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { select } from './select'; + +const examples: HelperExamples = { + helper: 'select', + category: 'array', + examples: [ + { + title: 'Filter and transform in one pass', + description: + 'Keeps only items matching the condition and transforms them — ' + + 'equivalent to .filter().map() but with a single iteration.', + code: `select([1, 2, 3, 4, 5], x => x * 2, x => x % 2 === 0) +// => [4, 8]`, + assert: () => { + const result = select([1, 2, 3, 4, 5], x => x * 2, x => x % 2 === 0); + if (result.length !== 2 || result[0] !== 4 || result[1] !== 8) { + throw new Error('Expected [4, 8]'); + } + }, + }, + { + title: 'Extract a field from matching objects', + description: 'Filter on a condition and pluck a specific property in a single readable call.', + code: `const users = [ + { name: 'Alice', active: true }, + { name: 'Bob', active: false }, + { name: 'Carol', active: true }, +]; +select(users, u => u.name, u => u.active) +// => ['Alice', 'Carol']`, + assert: () => { + const users = [ + { name: 'Alice', active: true }, + { name: 'Bob', active: false }, + { name: 'Carol', active: true }, + ]; + const result = select(users, u => u.name, u => u.active); + if (result.length !== 2 || result[0] !== 'Alice' || result[1] !== 'Carol') { + throw new Error('Expected [\'Alice\', \'Carol\']'); + } + }, + }, + ], +}; + +export default examples; diff --git a/helpers/array/select.spec.ts b/helpers/array/select.spec.ts new file mode 100644 index 00000000..621cd780 --- /dev/null +++ b/helpers/array/select.spec.ts @@ -0,0 +1,80 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { select } from './select'; + +describe('select — property-based', () => { + it('result length is at most input length', () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.func(fc.integer()), + fc.func(fc.boolean()), + (arr, mapper, condition) => { + expect(select(arr, mapper, condition).length).toBeLessThanOrEqual(arr.length); + }, + ), + ); + }); + + it('result length equals number of items passing the condition', () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.integer({ min: -50, max: 50 }), + (arr, threshold) => { + const condition = (x: number) => x > threshold; + const passing = arr.filter(condition); + expect(select(arr, x => x, condition).length).toBe(passing.length); + }, + ), + ); + }); +}); + +describe('select — contracts', () => { + it('is equivalent to .filter(condition).map(mapper)', () => { + fc.assert( + fc.property( + fc.array(fc.integer({ min: -100, max: 100 })), + (arr) => { + const condition = (x: number) => x > 0; + const mapper = (x: number) => x * 2; + expect(select(arr, mapper, condition)).toEqual( + arr.filter(condition).map(mapper), + ); + }, + ), + ); + }); + + it('without condition is equivalent to .map()', () => { + fc.assert( + fc.property(fc.array(fc.integer()), (arr) => { + const mapper = (x: number) => x * 3; + expect(select(arr, mapper)).toEqual(arr.map(mapper)); + }), + ); + }); + + it('mapper is never called for items failing the condition', () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.integer({ min: -50, max: 50 }), + (arr, threshold) => { + const condition = (x: number) => x > threshold; + const seen: number[] = []; + select(arr, x => { seen.push(x); return x; }, condition); + const passing = arr.filter(condition); + expect(seen).toEqual(passing); + }, + ), + ); + }); +}); diff --git a/helpers/array/select.test.ts b/helpers/array/select.test.ts new file mode 100644 index 00000000..2a0ea1d3 --- /dev/null +++ b/helpers/array/select.test.ts @@ -0,0 +1,52 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { select } from './select'; + +describe('select', () => { + it('should filter and map in one pass', () => { + expect(select([1, 2, 3, 4, 5], x => x * 2, x => x % 2 === 0)).toEqual([4, 8]); + }); + + it('should return all mapped values when no condition is given', () => { + expect(select([1, 2, 3], x => x * 10)).toEqual([10, 20, 30]); + }); + + it('should return an empty array when no items pass the condition', () => { + expect(select([1, 3, 5], x => x * 2, x => x % 2 === 0)).toEqual([]); + }); + + it('should return an empty array for an empty input', () => { + expect(select([], x => x, () => true)).toEqual([]); + }); + + it('should pass the index to both mapper and condition', () => { + const indices: number[] = []; + select([10, 20, 30], (x, i) => { indices.push(i); return x; }, (_, i) => i < 2); + expect(indices).toEqual([0, 1]); + }); + + it('should support mapping to a different type', () => { + const result = select( + [{ name: 'Alice', active: true }, { name: 'Bob', active: false }], + u => u.name, + u => u.active, + ); + expect(result).toEqual(['Alice']); + }); + + it('should not call mapper for items that fail the condition', () => { + let mapperCalls = 0; + select([1, 2, 3], x => { mapperCalls++; return x; }, x => x > 2); + expect(mapperCalls).toBe(1); + }); + + it('should work with readonly arrays', () => { + const arr: readonly number[] = [1, 2, 3, 4]; + expect(select(arr, x => x * 2, x => x > 2)).toEqual([6, 8]); + }); +}); diff --git a/helpers/array/select.ts b/helpers/array/select.ts new file mode 100644 index 00000000..a70b247f --- /dev/null +++ b/helpers/array/select.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Filters and transforms an array in a single pass. + * + * Equivalent to `.filter(condition).map(mapper)` but only iterates the array once, + * making it more efficient for large arrays or expensive conditions. + * + * @param array - The array to process + * @param mapper - Transforms each item that passes the condition + * @param condition - Determines which items to include; defaults to keeping all items + * @returns Mapped values for items that pass the condition + * @example + * // Keep only even numbers and double them + * select([1, 2, 3, 4, 5], x => x * 2, x => x % 2 === 0) + * // => [4, 8] + * + * @example + * // Extract active users' emails + * select(users, u => u.email, u => u.isActive) + * // => ['alice@example.com', 'bob@example.com'] + * + * @example + * // Without condition — equivalent to .map() + * select([1, 2, 3], x => x * 10) + * // => [10, 20, 30] + * @since next + */ +export function select( + array: readonly T[], + mapper: (item: T, index: number) => U, + condition: (item: T, index: number) => boolean = () => true, +): U[] { + const result: U[] = []; + for (let i = 0; i < array.length; i++) { + if (condition(array[i], i)) { + result.push(mapper(array[i], i)); + } + } + return result; +} From 631b9ace6905a2b2d90935065d02be00a7c1efbf Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 21:30:30 +0000 Subject: [PATCH 23/34] =?UTF-8?q?feat(date):=20=E2=9C=A8=20add=20isValid?= =?UTF-8?q?=20function=20and=20related=20tests=20-=20implement=20isValid?= =?UTF-8?q?=20function=20to=20check=20Date=20instances=20-=20add=20propert?= =?UTF-8?q?y-based=20and=20contract=20tests=20for=20isValid=20-=20remove?= =?UTF-8?q?=20outdated=20isValidDate=20tests=20and=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/date/isValid.spec.ts | 56 ++++++++++++++++++++++++++++++++ helpers/date/isValid.test.ts | 29 +++++++++++++++++ helpers/date/isValid.ts | 26 +++++++++++++++ helpers/type/isValidDate.spec.ts | 56 -------------------------------- helpers/type/isValidDate.test.ts | 29 ----------------- helpers/type/isValidDate.ts | 25 -------------- 6 files changed, 111 insertions(+), 110 deletions(-) create mode 100644 helpers/date/isValid.spec.ts create mode 100644 helpers/date/isValid.test.ts create mode 100644 helpers/date/isValid.ts delete mode 100644 helpers/type/isValidDate.spec.ts delete mode 100644 helpers/type/isValidDate.test.ts delete mode 100644 helpers/type/isValidDate.ts diff --git a/helpers/date/isValid.spec.ts b/helpers/date/isValid.spec.ts new file mode 100644 index 00000000..eee25ef9 --- /dev/null +++ b/helpers/date/isValid.spec.ts @@ -0,0 +1,56 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { isDate } from '../type/isDate'; +import { isValid } from './isValid'; + +describe('isValid — property-based', () => { + it('isValid(v) → isDate(v)', () => { + fc.assert( + fc.property(fc.date(), (d) => { + if (isValid(d)) { + expect(isDate(d)).toBe(true); + } + }), + ); + }); + + it('valid dates have finite getTime()', () => { + fc.assert( + fc.property(fc.date(), (d) => { + if (isValid(d)) { + expect(Number.isFinite(d.getTime())).toBe(true); + } + }), + ); + }); +}); + +describe('isValid — contract', () => { + it('new Date() → true', () => expect(isValid(new Date())).toBe(true)); + it('new Date(0) → true', () => expect(isValid(new Date(0))).toBe(true)); + it("new Date('2024-01-01') → true", () => expect(isValid(new Date('2024-01-01'))).toBe(true)); + it("new Date('invalid') → false", () => expect(isValid(new Date('invalid'))).toBe(false)); + it("'date string' → false", () => expect(isValid('2024-01-01')).toBe(false)); + it('null → false', () => expect(isValid(null)).toBe(false)); + it('undefined → false', () => expect(isValid(undefined)).toBe(false)); + it('Date.now() (number) → false', () => expect(isValid(Date.now())).toBe(false)); +}); + +describe('isValid — narrowing in if/else', () => { + it('narrows the value to Date in the then-branch', () => { + const v: unknown = new Date('2024-01-01'); + if (isValid(v)) { + expectTypeOf(v).toEqualTypeOf(); + expect(Number.isFinite(v.getTime())).toBe(true); + } else { + throw new Error('expected then-branch'); + } + expect(isValid(new Date('not-a-date'))).toBe(false); + }); +}); diff --git a/helpers/date/isValid.test.ts b/helpers/date/isValid.test.ts new file mode 100644 index 00000000..3fb53c99 --- /dev/null +++ b/helpers/date/isValid.test.ts @@ -0,0 +1,29 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isValid } from './isValid'; + +describe('isValid', () => { + it('should return true for valid Date instances', () => { + expect(isValid(new Date())).toBe(true); + expect(isValid(new Date('2023-01-01'))).toBe(true); + expect(isValid(new Date(0))).toBe(true); + }); + + it('should return false for invalid Date instances', () => { + expect(isValid(new Date('invalid'))).toBe(false); + expect(isValid(new Date(NaN))).toBe(false); + }); + + it('should return false for non-Date values', () => { + expect(isValid('2023-01-01')).toBe(false); + expect(isValid(1609459200000)).toBe(false); + expect(isValid(null)).toBe(false); + expect(isValid(undefined)).toBe(false); + expect(isValid({})).toBe(false); + }); +}); diff --git a/helpers/date/isValid.ts b/helpers/date/isValid.ts new file mode 100644 index 00000000..9c309c31 --- /dev/null +++ b/helpers/date/isValid.ts @@ -0,0 +1,26 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a valid Date instance (not `Invalid Date`). + * + * Unlike `isDate` (in `type/`), this also verifies that the internal timestamp + * is not `NaN`. + * + * @param value - The value to check + * @returns True if value is a Date instance with a valid time value + * @example + * isValid(new Date()) // => true + * isValid(new Date('invalid')) // => false + * isValid('2023-01-01') // => false (not a Date instance) + * + * @see `type/isDate` for checking only if value is a Date instance + * @see `type/isTimestamp` for checking if a number is a valid timestamp + * @since 2.0.0 + */ +export function isValid(value: unknown): value is Date { + return value instanceof Date && !Number.isNaN(value.getTime()); +} diff --git a/helpers/type/isValidDate.spec.ts b/helpers/type/isValidDate.spec.ts deleted file mode 100644 index 068fe273..00000000 --- a/helpers/type/isValidDate.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isValidDate } from './isValidDate'; -import { isDate } from './isDate'; - -describe('isValidDate — property-based', () => { - it('isValidDate(v) → isDate(v)', () => { - fc.assert( - fc.property(fc.date(), (d) => { - if (isValidDate(d)) { - expect(isDate(d)).toBe(true); - } - }), - ); - }); - - it('valid dates have finite getTime()', () => { - fc.assert( - fc.property(fc.date(), (d) => { - if (isValidDate(d)) { - expect(Number.isFinite(d.getTime())).toBe(true); - } - }), - ); - }); -}); - -describe('isValidDate — contract', () => { - it('new Date() → true', () => expect(isValidDate(new Date())).toBe(true)); - it('new Date(0) → true', () => expect(isValidDate(new Date(0))).toBe(true)); - it("new Date('2024-01-01') → true", () => expect(isValidDate(new Date('2024-01-01'))).toBe(true)); - it("new Date('invalid') → false", () => expect(isValidDate(new Date('invalid'))).toBe(false)); - it("'date string' → false", () => expect(isValidDate('2024-01-01')).toBe(false)); - it('null → false', () => expect(isValidDate(null)).toBe(false)); - it('undefined → false', () => expect(isValidDate(undefined)).toBe(false)); - it('Date.now() (number) → false', () => expect(isValidDate(Date.now())).toBe(false)); -}); - -describe('isValidDate — narrowing in if/else', () => { - it('narrows the value to Date in the then-branch', () => { - const v: unknown = new Date('2024-01-01'); - if (isValidDate(v)) { - expectTypeOf(v).toEqualTypeOf(); - expect(Number.isFinite(v.getTime())).toBe(true); - } else { - throw new Error('expected then-branch'); - } - expect(isValidDate(new Date('not-a-date'))).toBe(false); - }); -}); diff --git a/helpers/type/isValidDate.test.ts b/helpers/type/isValidDate.test.ts deleted file mode 100644 index 10a0269f..00000000 --- a/helpers/type/isValidDate.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isValidDate } from './isValidDate'; - -describe('isValidDate', () => { - it('should return true for valid Date instances', () => { - expect(isValidDate(new Date())).toBe(true); - expect(isValidDate(new Date('2023-01-01'))).toBe(true); - expect(isValidDate(new Date(0))).toBe(true); - }); - - it('should return false for invalid Date instances', () => { - expect(isValidDate(new Date('invalid'))).toBe(false); - expect(isValidDate(new Date(NaN))).toBe(false); - }); - - it('should return false for non-Date values', () => { - expect(isValidDate('2023-01-01')).toBe(false); - expect(isValidDate(1609459200000)).toBe(false); - expect(isValidDate(null)).toBe(false); - expect(isValidDate(undefined)).toBe(false); - expect(isValidDate({})).toBe(false); - }); -}); diff --git a/helpers/type/isValidDate.ts b/helpers/type/isValidDate.ts deleted file mode 100644 index 5c827d1d..00000000 --- a/helpers/type/isValidDate.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -/** - * Checks if a value is a valid Date instance (not `Invalid Date`). - * - * Unlike {@link isDate}, this also verifies that the internal timestamp is not `NaN`. - * - * @param value - The value to check - * @returns True if value is a Date instance with a valid time value - * @example - * isValidDate(new Date()) // => true - * isValidDate(new Date('invalid')) // => false - * isValidDate('2023-01-01') // => false (not a Date instance) - * - * @see {@link isDate} for checking only if value is a Date instance - * @see {@link isTimestamp} for checking if a number is a valid timestamp - * @since 2.0.0 - */ -export function isValidDate(value: unknown): value is Date { - return value instanceof Date && !Number.isNaN(value.getTime()); -} From 569e086ff19cf95ce95945a2a4f599ded3e93594 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 21:30:49 +0000 Subject: [PATCH 24/34] =?UTF-8?q?feat(number):=20=E2=9C=A8=20add=20isPosit?= =?UTF-8?q?iveNumber=20function=20with=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/number/isPositive.spec.ts | 55 +++++++++++++++++++ helpers/number/isPositive.test.ts | 37 +++++++++++++ .../isPositive.ts} | 12 ++-- helpers/type/isPositiveNumber.spec.ts | 55 ------------------- helpers/type/isPositiveNumber.test.ts | 37 ------------- 5 files changed, 98 insertions(+), 98 deletions(-) create mode 100644 helpers/number/isPositive.spec.ts create mode 100644 helpers/number/isPositive.test.ts rename helpers/{type/isPositiveNumber.ts => number/isPositive.ts} (62%) delete mode 100644 helpers/type/isPositiveNumber.spec.ts delete mode 100644 helpers/type/isPositiveNumber.test.ts diff --git a/helpers/number/isPositive.spec.ts b/helpers/number/isPositive.spec.ts new file mode 100644 index 00000000..777a5642 --- /dev/null +++ b/helpers/number/isPositive.spec.ts @@ -0,0 +1,55 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { isNumber } from '../type/isNumber'; +import { isNegative } from './isNegative'; +import { isPositive } from './isPositive'; + +describe('isPositive — property-based', () => { + it('isPositive(v) → isNumber(v)', () => { + fc.assert( + fc.property(fc.double({ noNaN: true, min: Number.EPSILON, max: 1e10 }), (v) => { + expect(isPositive(v)).toBe(true); + expect(isNumber(v)).toBe(true); + }), + ); + }); + + it('isPositive(v) → !isNegative(v)', () => { + fc.assert( + fc.property(fc.double({ noNaN: true, min: Number.EPSILON, max: 1e10 }), (v) => { + expect(isPositive(v)).toBe(true); + expect(isNegative(v)).toBe(false); + }), + ); + }); +}); + +describe('isPositive — contract', () => { + it('1 → true', () => expect(isPositive(1)).toBe(true)); + it('0.1 → true', () => expect(isPositive(0.1)).toBe(true)); + it('Infinity → true', () => expect(isPositive(Infinity)).toBe(true)); + it('0 → false', () => expect(isPositive(0)).toBe(false)); + it('-1 → false', () => expect(isPositive(-1)).toBe(false)); + it('NaN → false', () => expect(isPositive(NaN)).toBe(false)); + it('-Infinity → false', () => expect(isPositive(-Infinity)).toBe(false)); + it('null → false', () => expect(isPositive(null)).toBe(false)); +}); + +describe('isPositive — narrowing in if/else', () => { + it('narrows the value to number in the then-branch', () => { + const v: unknown = 1; + if (isPositive(v)) { + expectTypeOf(v).toEqualTypeOf(); + expect(v).toBeGreaterThan(0); + } else { + throw new Error('expected then-branch'); + } + expect(isPositive(-1)).toBe(false); + }); +}); diff --git a/helpers/number/isPositive.test.ts b/helpers/number/isPositive.test.ts new file mode 100644 index 00000000..704f863f --- /dev/null +++ b/helpers/number/isPositive.test.ts @@ -0,0 +1,37 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isPositive } from './isPositive'; + +describe('isPositive', () => { + it('should return true for positive numbers', () => { + expect(isPositive(1)).toBe(true); + expect(isPositive(42)).toBe(true); + expect(isPositive(0.1)).toBe(true); + expect(isPositive(Infinity)).toBe(true); + }); + + it('should return false for zero', () => { + expect(isPositive(0)).toBe(false); + }); + + it('should return false for negative numbers', () => { + expect(isPositive(-1)).toBe(false); + expect(isPositive(-0.1)).toBe(false); + }); + + it('should return false for NaN', () => { + expect(isPositive(NaN)).toBe(false); + }); + + it('should return false for non-numbers', () => { + expect(isPositive('42')).toBe(false); + expect(isPositive(true)).toBe(false); + expect(isPositive(null)).toBe(false); + expect(isPositive(undefined)).toBe(false); + }); +}); diff --git a/helpers/type/isPositiveNumber.ts b/helpers/number/isPositive.ts similarity index 62% rename from helpers/type/isPositiveNumber.ts rename to helpers/number/isPositive.ts index dc78bb07..260456f4 100644 --- a/helpers/type/isPositiveNumber.ts +++ b/helpers/number/isPositive.ts @@ -12,13 +12,13 @@ * @param value - The value to check * @returns True if value is a positive number * @example - * isPositiveNumber(42) // => true - * isPositiveNumber(0.1) // => true - * isPositiveNumber(0) // => false - * isPositiveNumber(-1) // => false - * isPositiveNumber(NaN) // => false + * isPositive(42) // => true + * isPositive(0.1) // => true + * isPositive(0) // => false + * isPositive(-1) // => false + * isPositive(NaN) // => false * @since 2.0.0 */ -export function isPositiveNumber(value: unknown): value is number { +export function isPositive(value: unknown): value is number { return typeof value === 'number' && value > 0; } diff --git a/helpers/type/isPositiveNumber.spec.ts b/helpers/type/isPositiveNumber.spec.ts deleted file mode 100644 index e1b1a6e5..00000000 --- a/helpers/type/isPositiveNumber.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isPositiveNumber } from './isPositiveNumber'; -import { isNumber } from './isNumber'; -import { isNegativeNumber } from './isNegativeNumber'; - -describe('isPositiveNumber — property-based', () => { - it('isPositiveNumber(v) → isNumber(v)', () => { - fc.assert( - fc.property(fc.double({ noNaN: true, min: Number.EPSILON, max: 1e10 }), (v) => { - expect(isPositiveNumber(v)).toBe(true); - expect(isNumber(v)).toBe(true); - }), - ); - }); - - it('isPositiveNumber(v) → !isNegativeNumber(v)', () => { - fc.assert( - fc.property(fc.double({ noNaN: true, min: Number.EPSILON, max: 1e10 }), (v) => { - expect(isPositiveNumber(v)).toBe(true); - expect(isNegativeNumber(v)).toBe(false); - }), - ); - }); -}); - -describe('isPositiveNumber — contract', () => { - it('1 → true', () => expect(isPositiveNumber(1)).toBe(true)); - it('0.1 → true', () => expect(isPositiveNumber(0.1)).toBe(true)); - it('Infinity → true', () => expect(isPositiveNumber(Infinity)).toBe(true)); - it('0 → false', () => expect(isPositiveNumber(0)).toBe(false)); - it('-1 → false', () => expect(isPositiveNumber(-1)).toBe(false)); - it('NaN → false', () => expect(isPositiveNumber(NaN)).toBe(false)); - it('-Infinity → false', () => expect(isPositiveNumber(-Infinity)).toBe(false)); - it('null → false', () => expect(isPositiveNumber(null)).toBe(false)); -}); - -describe('isPositiveNumber — narrowing in if/else', () => { - it('narrows the value to number in the then-branch', () => { - const v: unknown = 1; - if (isPositiveNumber(v)) { - expectTypeOf(v).toEqualTypeOf(); - expect(v).toBeGreaterThan(0); - } else { - throw new Error('expected then-branch'); - } - expect(isPositiveNumber(-1)).toBe(false); - }); -}); diff --git a/helpers/type/isPositiveNumber.test.ts b/helpers/type/isPositiveNumber.test.ts deleted file mode 100644 index e6bd8796..00000000 --- a/helpers/type/isPositiveNumber.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isPositiveNumber } from './isPositiveNumber'; - -describe('isPositiveNumber', () => { - it('should return true for positive numbers', () => { - expect(isPositiveNumber(1)).toBe(true); - expect(isPositiveNumber(42)).toBe(true); - expect(isPositiveNumber(0.1)).toBe(true); - expect(isPositiveNumber(Infinity)).toBe(true); - }); - - it('should return false for zero', () => { - expect(isPositiveNumber(0)).toBe(false); - }); - - it('should return false for negative numbers', () => { - expect(isPositiveNumber(-1)).toBe(false); - expect(isPositiveNumber(-0.1)).toBe(false); - }); - - it('should return false for NaN', () => { - expect(isPositiveNumber(NaN)).toBe(false); - }); - - it('should return false for non-numbers', () => { - expect(isPositiveNumber('42')).toBe(false); - expect(isPositiveNumber(true)).toBe(false); - expect(isPositiveNumber(null)).toBe(false); - expect(isPositiveNumber(undefined)).toBe(false); - }); -}); From cf684d0b1eafd56c25a06714cf245c18e0d6eea8 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 21:32:39 +0000 Subject: [PATCH 25/34] =?UTF-8?q?feat(number):=20=E2=9C=A8=20add=20isNegat?= =?UTF-8?q?ive=20function=20with=20tests=20-=20implement=20isNegative=20fu?= =?UTF-8?q?nction=20to=20check=20for=20negative=20numbers=20-=20add=20prop?= =?UTF-8?q?erty-based=20and=20contract=20tests=20for=20isNegative=20-=20re?= =?UTF-8?q?move=20isNegativeNumber=20tests=20and=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/number/isNegative.spec.ts | 55 +++++++++++++++++++ helpers/number/isNegative.test.ts | 36 ++++++++++++ .../isNegative.ts} | 12 ++-- helpers/type/isNegativeNumber.spec.ts | 55 ------------------- helpers/type/isNegativeNumber.test.ts | 36 ------------ 5 files changed, 97 insertions(+), 97 deletions(-) create mode 100644 helpers/number/isNegative.spec.ts create mode 100644 helpers/number/isNegative.test.ts rename helpers/{type/isNegativeNumber.ts => number/isNegative.ts} (61%) delete mode 100644 helpers/type/isNegativeNumber.spec.ts delete mode 100644 helpers/type/isNegativeNumber.test.ts diff --git a/helpers/number/isNegative.spec.ts b/helpers/number/isNegative.spec.ts new file mode 100644 index 00000000..0d590940 --- /dev/null +++ b/helpers/number/isNegative.spec.ts @@ -0,0 +1,55 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { isNumber } from '../type/isNumber'; +import { isNegative } from './isNegative'; +import { isPositive } from './isPositive'; + +describe('isNegative — property-based', () => { + it('isNegative(v) → isNumber(v)', () => { + fc.assert( + fc.property(fc.double({ noNaN: true, min: -1e10, max: -Number.EPSILON }), (v) => { + expect(isNegative(v)).toBe(true); + expect(isNumber(v)).toBe(true); + }), + ); + }); + + it('isNegative(v) → !isPositive(v)', () => { + fc.assert( + fc.property(fc.double({ noNaN: true, min: -1e10, max: -Number.EPSILON }), (v) => { + expect(isNegative(v)).toBe(true); + expect(isPositive(v)).toBe(false); + }), + ); + }); +}); + +describe('isNegative — contract', () => { + it('-1 → true', () => expect(isNegative(-1)).toBe(true)); + it('-0.1 → true', () => expect(isNegative(-0.1)).toBe(true)); + it('-Infinity → true', () => expect(isNegative(-Infinity)).toBe(true)); + it('0 → false', () => expect(isNegative(0)).toBe(false)); + it('-0 → false (-0 < 0 is false)', () => expect(isNegative(-0)).toBe(false)); + it('1 → false', () => expect(isNegative(1)).toBe(false)); + it('NaN → false', () => expect(isNegative(NaN)).toBe(false)); + it('null → false', () => expect(isNegative(null)).toBe(false)); +}); + +describe('isNegative — narrowing in if/else', () => { + it('narrows the value to number in the then-branch', () => { + const v: unknown = -1; + if (isNegative(v)) { + expectTypeOf(v).toEqualTypeOf(); + expect(v).toBeLessThan(0); + } else { + throw new Error('expected then-branch'); + } + expect(isNegative(1)).toBe(false); + }); +}); diff --git a/helpers/number/isNegative.test.ts b/helpers/number/isNegative.test.ts new file mode 100644 index 00000000..40123b88 --- /dev/null +++ b/helpers/number/isNegative.test.ts @@ -0,0 +1,36 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNegative } from './isNegative'; + +describe('isNegative', () => { + it('should return true for negative numbers', () => { + expect(isNegative(-1)).toBe(true); + expect(isNegative(-0.5)).toBe(true); + expect(isNegative(-Infinity)).toBe(true); + }); + + it('should return false for zero', () => { + expect(isNegative(0)).toBe(false); + }); + + it('should return false for positive numbers', () => { + expect(isNegative(1)).toBe(false); + expect(isNegative(0.1)).toBe(false); + }); + + it('should return false for NaN', () => { + expect(isNegative(NaN)).toBe(false); + }); + + it('should return false for non-numbers', () => { + expect(isNegative('-1')).toBe(false); + expect(isNegative(false)).toBe(false); + expect(isNegative(null)).toBe(false); + expect(isNegative(undefined)).toBe(false); + }); +}); diff --git a/helpers/type/isNegativeNumber.ts b/helpers/number/isNegative.ts similarity index 61% rename from helpers/type/isNegativeNumber.ts rename to helpers/number/isNegative.ts index bd6e1fbc..a097978b 100644 --- a/helpers/type/isNegativeNumber.ts +++ b/helpers/number/isNegative.ts @@ -12,13 +12,13 @@ * @param value - The value to check * @returns True if value is a negative number * @example - * isNegativeNumber(-1) // => true - * isNegativeNumber(-0.5) // => true - * isNegativeNumber(0) // => false - * isNegativeNumber(1) // => false - * isNegativeNumber(NaN) // => false + * isNegative(-1) // => true + * isNegative(-0.5) // => true + * isNegative(0) // => false + * isNegative(1) // => false + * isNegative(NaN) // => false * @since 2.0.0 */ -export function isNegativeNumber(value: unknown): value is number { +export function isNegative(value: unknown): value is number { return typeof value === 'number' && value < 0; } diff --git a/helpers/type/isNegativeNumber.spec.ts b/helpers/type/isNegativeNumber.spec.ts deleted file mode 100644 index f502e1e9..00000000 --- a/helpers/type/isNegativeNumber.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isNegativeNumber } from './isNegativeNumber'; -import { isNumber } from './isNumber'; -import { isPositiveNumber } from './isPositiveNumber'; - -describe('isNegativeNumber — property-based', () => { - it('isNegativeNumber(v) → isNumber(v)', () => { - fc.assert( - fc.property(fc.double({ noNaN: true, min: -1e10, max: -Number.EPSILON }), (v) => { - expect(isNegativeNumber(v)).toBe(true); - expect(isNumber(v)).toBe(true); - }), - ); - }); - - it('isNegativeNumber(v) → !isPositiveNumber(v)', () => { - fc.assert( - fc.property(fc.double({ noNaN: true, min: -1e10, max: -Number.EPSILON }), (v) => { - expect(isNegativeNumber(v)).toBe(true); - expect(isPositiveNumber(v)).toBe(false); - }), - ); - }); -}); - -describe('isNegativeNumber — contract', () => { - it('-1 → true', () => expect(isNegativeNumber(-1)).toBe(true)); - it('-0.1 → true', () => expect(isNegativeNumber(-0.1)).toBe(true)); - it('-Infinity → true', () => expect(isNegativeNumber(-Infinity)).toBe(true)); - it('0 → false', () => expect(isNegativeNumber(0)).toBe(false)); - it('-0 → false (-0 < 0 is false)', () => expect(isNegativeNumber(-0)).toBe(false)); - it('1 → false', () => expect(isNegativeNumber(1)).toBe(false)); - it('NaN → false', () => expect(isNegativeNumber(NaN)).toBe(false)); - it('null → false', () => expect(isNegativeNumber(null)).toBe(false)); -}); - -describe('isNegativeNumber — narrowing in if/else', () => { - it('narrows the value to number in the then-branch', () => { - const v: unknown = -1; - if (isNegativeNumber(v)) { - expectTypeOf(v).toEqualTypeOf(); - expect(v).toBeLessThan(0); - } else { - throw new Error('expected then-branch'); - } - expect(isNegativeNumber(1)).toBe(false); - }); -}); diff --git a/helpers/type/isNegativeNumber.test.ts b/helpers/type/isNegativeNumber.test.ts deleted file mode 100644 index b4481227..00000000 --- a/helpers/type/isNegativeNumber.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isNegativeNumber } from './isNegativeNumber'; - -describe('isNegativeNumber', () => { - it('should return true for negative numbers', () => { - expect(isNegativeNumber(-1)).toBe(true); - expect(isNegativeNumber(-0.5)).toBe(true); - expect(isNegativeNumber(-Infinity)).toBe(true); - }); - - it('should return false for zero', () => { - expect(isNegativeNumber(0)).toBe(false); - }); - - it('should return false for positive numbers', () => { - expect(isNegativeNumber(1)).toBe(false); - expect(isNegativeNumber(0.1)).toBe(false); - }); - - it('should return false for NaN', () => { - expect(isNegativeNumber(NaN)).toBe(false); - }); - - it('should return false for non-numbers', () => { - expect(isNegativeNumber('-1')).toBe(false); - expect(isNegativeNumber(false)).toBe(false); - expect(isNegativeNumber(null)).toBe(false); - expect(isNegativeNumber(undefined)).toBe(false); - }); -}); From f31f9ac98014732ee0d65895e0b11faa17802969 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 21:33:05 +0000 Subject: [PATCH 26/34] =?UTF-8?q?docs(type):=20=F0=9F=93=9D=20update=20lin?= =?UTF-8?q?ks=20to=20use=20date/isValid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isDate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helpers/type/isDate.ts b/helpers/type/isDate.ts index 8b6c68de..1057e6a5 100644 --- a/helpers/type/isDate.ts +++ b/helpers/type/isDate.ts @@ -8,7 +8,7 @@ * Checks if a value is a Date instance. * * Note: this only checks the type, not whether the Date is valid. - * Use {@link isValidDate} to also validate that the Date is not `Invalid Date`. + * Use `date/isValid` to also validate that the Date is not `Invalid Date`. * * @param value - The value to check * @returns True if value is a Date instance @@ -18,7 +18,7 @@ * isDate('2023-01-01') // => false * isDate(1609459200000) // => false * - * @see {@link isValidDate} for validating the Date is not Invalid Date + * @see `date/isValid` for validating the Date is not Invalid Date * @see {@link isTimestamp} for checking if a number is a valid timestamp * @since 2.0.0 */ From d368e87ae9f47ef784f9aae23bd5abfcee66765a Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 21:33:15 +0000 Subject: [PATCH 27/34] =?UTF-8?q?chore:=20=F0=9F=94=A7=20remove=20isNonEmp?= =?UTF-8?q?tyArray=20and=20isNonEmptyString=20implementations=20and=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/type/isNonEmptyArray.spec.ts | 55 --------------------------- helpers/type/isNonEmptyArray.test.ts | 29 -------------- helpers/type/isNonEmptyArray.ts | 20 ---------- helpers/type/isNonEmptyString.spec.ts | 55 --------------------------- helpers/type/isNonEmptyString.test.ts | 29 -------------- helpers/type/isNonEmptyString.ts | 20 ---------- 6 files changed, 208 deletions(-) delete mode 100644 helpers/type/isNonEmptyArray.spec.ts delete mode 100644 helpers/type/isNonEmptyArray.test.ts delete mode 100644 helpers/type/isNonEmptyArray.ts delete mode 100644 helpers/type/isNonEmptyString.spec.ts delete mode 100644 helpers/type/isNonEmptyString.test.ts delete mode 100644 helpers/type/isNonEmptyString.ts diff --git a/helpers/type/isNonEmptyArray.spec.ts b/helpers/type/isNonEmptyArray.spec.ts deleted file mode 100644 index 649287d1..00000000 --- a/helpers/type/isNonEmptyArray.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isNonEmptyArray } from './isNonEmptyArray'; -import { isArray } from './isArray'; -import { isEmpty } from './isEmpty'; - -describe('isNonEmptyArray — property-based', () => { - it('isNonEmptyArray(v) → isArray(v)', () => { - fc.assert( - fc.property(fc.array(fc.anything(), { minLength: 1 }), (arr) => { - expect(isNonEmptyArray(arr)).toBe(true); - expect(isArray(arr)).toBe(true); - }), - ); - }); - - it('isNonEmptyArray(v) → !isEmpty(v)', () => { - fc.assert( - fc.property(fc.array(fc.anything(), { minLength: 1 }), (arr) => { - expect(isNonEmptyArray(arr)).toBe(true); - expect(isEmpty(arr)).toBe(false); - }), - ); - }); -}); - -describe('isNonEmptyArray — contract', () => { - it('[1] → true', () => expect(isNonEmptyArray([1])).toBe(true)); - it('[undefined] → true (has element even if undefined)', () => expect(isNonEmptyArray([undefined])).toBe(true)); - it('[null] → true', () => expect(isNonEmptyArray([null])).toBe(true)); - it('[] → false', () => expect(isNonEmptyArray([])).toBe(false)); - it('{} → false', () => expect(isNonEmptyArray({})).toBe(false)); - it('null → false', () => expect(isNonEmptyArray(null)).toBe(false)); - it('undefined → false', () => expect(isNonEmptyArray(undefined)).toBe(false)); - it("'abc' → false", () => expect(isNonEmptyArray('abc')).toBe(false)); -}); - -describe('isNonEmptyArray — narrowing in if/else', () => { - it('narrows the value to a non-empty tuple in the then-branch', () => { - const v: unknown = [1]; - if (isNonEmptyArray(v)) { - expectTypeOf(v).toEqualTypeOf<[unknown, ...unknown[]]>(); - expect(v[0]).toBe(1); - } else { - throw new Error('expected then-branch'); - } - expect(isNonEmptyArray([])).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyArray.test.ts b/helpers/type/isNonEmptyArray.test.ts deleted file mode 100644 index a04f84e2..00000000 --- a/helpers/type/isNonEmptyArray.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isNonEmptyArray } from './isNonEmptyArray'; - -describe('isNonEmptyArray', () => { - it('should return true for non-empty arrays', () => { - expect(isNonEmptyArray([1])).toBe(true); - expect(isNonEmptyArray([1, 2, 3])).toBe(true); - expect(isNonEmptyArray([undefined])).toBe(true); - }); - - it('should return false for empty array', () => { - expect(isNonEmptyArray([])).toBe(false); - }); - - it('should return false for non-arrays', () => { - expect(isNonEmptyArray('abc')).toBe(false); - expect(isNonEmptyArray(42)).toBe(false); - expect(isNonEmptyArray(null)).toBe(false); - expect(isNonEmptyArray(undefined)).toBe(false); - expect(isNonEmptyArray({})).toBe(false); - expect(isNonEmptyArray(new Set([1]))).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyArray.ts b/helpers/type/isNonEmptyArray.ts deleted file mode 100644 index e75a2683..00000000 --- a/helpers/type/isNonEmptyArray.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -/** - * Checks if a value is a non-empty array (length > 0). - * @param value - The value to check - * @returns True if value is an array with at least one element - * @example - * isNonEmptyArray([1, 2]) // => true - * isNonEmptyArray([]) // => false - * isNonEmptyArray('abc') // => false - * isNonEmptyArray(null) // => false - * @since 2.0.0 - */ -export function isNonEmptyArray(value: unknown): value is [unknown, ...unknown[]] { - return Array.isArray(value) && value.length > 0; -} diff --git a/helpers/type/isNonEmptyString.spec.ts b/helpers/type/isNonEmptyString.spec.ts deleted file mode 100644 index 070e2462..00000000 --- a/helpers/type/isNonEmptyString.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isNonEmptyString } from './isNonEmptyString'; -import { isString } from './isString'; -import { isEmpty } from './isEmpty'; - -describe('isNonEmptyString — property-based', () => { - it('isNonEmptyString(v) → isString(v)', () => { - fc.assert( - fc.property(fc.string({ minLength: 1 }), (s) => { - expect(isNonEmptyString(s)).toBe(true); - expect(isString(s)).toBe(true); - }), - ); - }); - - it('isNonEmptyString(v) → !isEmpty(v)', () => { - fc.assert( - fc.property(fc.string({ minLength: 1 }), (s) => { - expect(isNonEmptyString(s)).toBe(true); - expect(isEmpty(s)).toBe(false); - }), - ); - }); -}); - -describe('isNonEmptyString — contract', () => { - it("'a' → true", () => expect(isNonEmptyString('a')).toBe(true)); - it("' ' → true (space is non-empty)", () => expect(isNonEmptyString(' ')).toBe(true)); - it("'hello world' → true", () => expect(isNonEmptyString('hello world')).toBe(true)); - it("'' → false", () => expect(isNonEmptyString('')).toBe(false)); - it('null → false', () => expect(isNonEmptyString(null)).toBe(false)); - it('undefined → false', () => expect(isNonEmptyString(undefined)).toBe(false)); - it('0 → false', () => expect(isNonEmptyString(0)).toBe(false)); - it('[] → false', () => expect(isNonEmptyString([])).toBe(false)); -}); - -describe('isNonEmptyString — narrowing in if/else', () => { - it('narrows the value to string in the then-branch', () => { - const v: unknown = 'a'; - if (isNonEmptyString(v)) { - expectTypeOf(v).toEqualTypeOf(); - expect(v.length).toBe(1); - } else { - throw new Error('expected then-branch'); - } - expect(isNonEmptyString('')).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyString.test.ts b/helpers/type/isNonEmptyString.test.ts deleted file mode 100644 index 18d6d58e..00000000 --- a/helpers/type/isNonEmptyString.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isNonEmptyString } from './isNonEmptyString'; - -describe('isNonEmptyString', () => { - it('should return true for non-empty strings', () => { - expect(isNonEmptyString('hello')).toBe(true); - expect(isNonEmptyString(' ')).toBe(true); - expect(isNonEmptyString('0')).toBe(true); - }); - - it('should return false for empty string', () => { - expect(isNonEmptyString('')).toBe(false); - }); - - it('should return false for non-strings', () => { - expect(isNonEmptyString(42)).toBe(false); - expect(isNonEmptyString(true)).toBe(false); - expect(isNonEmptyString(null)).toBe(false); - expect(isNonEmptyString(undefined)).toBe(false); - expect(isNonEmptyString([])).toBe(false); - expect(isNonEmptyString({})).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyString.ts b/helpers/type/isNonEmptyString.ts deleted file mode 100644 index 658e2904..00000000 --- a/helpers/type/isNonEmptyString.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -/** - * Checks if a value is a non-empty string (length > 0). - * @param value - The value to check - * @returns True if value is a string with at least one character - * @example - * isNonEmptyString('hello') // => true - * isNonEmptyString('') // => false - * isNonEmptyString(42) // => false - * isNonEmptyString(null) // => false - * @since 2.0.0 - */ -export function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0; -} From b08de9adf2d33e368a42d81adadc25f215e499c1 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 21:52:57 +0000 Subject: [PATCH 28/34] fix(review): address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clarify select() index semantics in JSDoc and spec (original index, not post-filter) - add explicit test for select() original-index behavior - fix dangling @see {@link isValidDate} in isTimestamp.ts → date/isValid - document Infinity/-Infinity in isPositive/isNegative examples Co-Authored-By: Claude Sonnet 4.6 --- helpers/array/select.spec.ts | 8 +++++++- helpers/array/select.ts | 7 +++++-- helpers/number/isNegative.ts | 11 ++++++----- helpers/number/isPositive.ts | 11 ++++++----- helpers/type/isTimestamp.ts | 2 +- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/helpers/array/select.spec.ts b/helpers/array/select.spec.ts index 621cd780..9f9d453b 100644 --- a/helpers/array/select.spec.ts +++ b/helpers/array/select.spec.ts @@ -38,7 +38,7 @@ describe('select — property-based', () => { }); describe('select — contracts', () => { - it('is equivalent to .filter(condition).map(mapper)', () => { + it('matches .filter(condition).map(mapper) for index-agnostic callbacks', () => { fc.assert( fc.property( fc.array(fc.integer({ min: -100, max: 100 })), @@ -53,6 +53,12 @@ describe('select — contracts', () => { ); }); + it('passes the original array index (not post-filter index) to mapper', () => { + // select([10, 20, 30], (_, i) => i, (_, i) => i === 2) → [2] + // .filter().map() would yield [0] because filter re-indexes + expect(select([10, 20, 30], (_x, i) => i, (_x, i) => i === 2)).toEqual([2]); + }); + it('without condition is equivalent to .map()', () => { fc.assert( fc.property(fc.array(fc.integer()), (arr) => { diff --git a/helpers/array/select.ts b/helpers/array/select.ts index a70b247f..60e5fe96 100644 --- a/helpers/array/select.ts +++ b/helpers/array/select.ts @@ -7,8 +7,11 @@ /** * Filters and transforms an array in a single pass. * - * Equivalent to `.filter(condition).map(mapper)` but only iterates the array once, - * making it more efficient for large arrays or expensive conditions. + * Similar to `.filter(condition).map(mapper)` but iterates the array only once. + * **Index semantics differ from `.filter().map()`:** the `index` passed to both + * `condition` and `mapper` is the index in the **original** array, not the + * post-filter position. Use index-agnostic callbacks when the two must behave + * identically. * * @param array - The array to process * @param mapper - Transforms each item that passes the condition diff --git a/helpers/number/isNegative.ts b/helpers/number/isNegative.ts index a097978b..3fa059f8 100644 --- a/helpers/number/isNegative.ts +++ b/helpers/number/isNegative.ts @@ -12,11 +12,12 @@ * @param value - The value to check * @returns True if value is a negative number * @example - * isNegative(-1) // => true - * isNegative(-0.5) // => true - * isNegative(0) // => false - * isNegative(1) // => false - * isNegative(NaN) // => false + * isNegative(-1) // => true + * isNegative(-0.5) // => true + * isNegative(-Infinity) // => true + * isNegative(0) // => false + * isNegative(1) // => false + * isNegative(NaN) // => false * @since 2.0.0 */ export function isNegative(value: unknown): value is number { diff --git a/helpers/number/isPositive.ts b/helpers/number/isPositive.ts index 260456f4..fe03dc8b 100644 --- a/helpers/number/isPositive.ts +++ b/helpers/number/isPositive.ts @@ -12,11 +12,12 @@ * @param value - The value to check * @returns True if value is a positive number * @example - * isPositive(42) // => true - * isPositive(0.1) // => true - * isPositive(0) // => false - * isPositive(-1) // => false - * isPositive(NaN) // => false + * isPositive(42) // => true + * isPositive(0.1) // => true + * isPositive(Infinity) // => true + * isPositive(0) // => false + * isPositive(-1) // => false + * isPositive(NaN) // => false * @since 2.0.0 */ export function isPositive(value: unknown): value is number { diff --git a/helpers/type/isTimestamp.ts b/helpers/type/isTimestamp.ts index a89cc075..1ff18a8e 100644 --- a/helpers/type/isTimestamp.ts +++ b/helpers/type/isTimestamp.ts @@ -36,7 +36,7 @@ const MAX_UNIX_SECONDS = 7258118400; * isTimestamp('1609459200') // => false (not a number) * * @see {@link isDate} for checking if a value is a Date instance - * @see {@link isValidDate} for checking if a Date instance is valid + * @see `date/isValid` for checking if a Date instance is valid * @since 2.0.0 */ export function isTimestamp(value: unknown): value is number { From f553c68d7759956dad71a1a179fefd8c0114a938 Mon Sep 17 00:00:00 2001 From: baxyz Date: Sun, 14 Jun 2026 21:58:32 +0000 Subject: [PATCH 29/34] fix(ci): fix lint and build failures - add eslint-disable unicorn/no-thenable comments in isPromiseLike tests and example - remove top-level node:stream import from isNodeStream.example.ts to fix build-website-metadata license lookup (node:stream is a built-in, not a package) Co-Authored-By: Claude Sonnet 4.6 --- helpers/node/isNodeStream.example.ts | 4 +--- helpers/type/isPromiseLike.example.ts | 2 ++ helpers/type/isPromiseLike.spec.ts | 3 +++ helpers/type/isPromiseLike.test.ts | 6 ++++++ 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/helpers/node/isNodeStream.example.ts b/helpers/node/isNodeStream.example.ts index e25b6808..4aae3656 100644 --- a/helpers/node/isNodeStream.example.ts +++ b/helpers/node/isNodeStream.example.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: LGPL-3.0-or-later */ -import { Readable, Writable } from 'node:stream'; import type { HelperExamples } from '../../scripts/examples/types'; import { isNodeStream } from './isNodeStream'; @@ -20,8 +19,7 @@ isNodeStream(new Readable({ read() {} })) // => true isNodeStream({}) // => false isNodeStream(null) // => false`, assert: () => { - if (!isNodeStream(new Readable({ read() {} }))) throw new Error('Readable should be a stream'); - if (!isNodeStream(new Writable({ write() {} }))) throw new Error('Writable should be a stream'); + if (!isNodeStream({ pipe: () => {} })) throw new Error('object with pipe should be a stream'); if (isNodeStream({})) throw new Error('{} should not be a stream'); if (isNodeStream(null)) throw new Error('null should not be a stream'); }, diff --git a/helpers/type/isPromiseLike.example.ts b/helpers/type/isPromiseLike.example.ts index 3580567d..7f6d0a00 100644 --- a/helpers/type/isPromiseLike.example.ts +++ b/helpers/type/isPromiseLike.example.ts @@ -21,8 +21,10 @@ isPromiseLike(null) // => false isPromiseLike({ then: 'not-a-fn' }) // => false`, assert: () => { if (!isPromiseLike(Promise.resolve(1))) throw new Error('Promise should be PromiseLike'); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection if (!isPromiseLike({ then: () => {} })) throw new Error('thenable should be PromiseLike'); if (isPromiseLike(42)) throw new Error('number should not be PromiseLike'); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection if (isPromiseLike({ then: 'not-a-fn' })) throw new Error('non-fn then should return false'); }, }, diff --git a/helpers/type/isPromiseLike.spec.ts b/helpers/type/isPromiseLike.spec.ts index 919ab7d8..2aa0d0ef 100644 --- a/helpers/type/isPromiseLike.spec.ts +++ b/helpers/type/isPromiseLike.spec.ts @@ -22,10 +22,13 @@ describe('isPromiseLike — contract', () => { it('null → false', () => expect(isPromiseLike(null)).toBe(false)); it('undefined → false', () => expect(isPromiseLike(undefined)).toBe(false)); it('Promise.resolve() → true', () => expect(isPromiseLike(Promise.resolve())).toBe(true)); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection it('{ then: fn } → true', () => expect(isPromiseLike({ then: () => {} })).toBe(true)); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection it('{ then: non-fn } → false', () => expect(isPromiseLike({ then: 42 })).toBe(false)); it('{} → false', () => expect(isPromiseLike({})).toBe(false)); it('function with .then → true', () => { + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection const fn = Object.assign(() => {}, { then: () => {} }); expect(isPromiseLike(fn)).toBe(true); }); diff --git a/helpers/type/isPromiseLike.test.ts b/helpers/type/isPromiseLike.test.ts index 6d762570..6d4600d8 100644 --- a/helpers/type/isPromiseLike.test.ts +++ b/helpers/type/isPromiseLike.test.ts @@ -15,18 +15,24 @@ describe('isPromiseLike', () => { }); it('should return true for objects with a then method', () => { + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection expect(isPromiseLike({ then: () => {} })).toBe(true); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection expect(isPromiseLike({ then: () => {}, catch: () => {} })).toBe(true); }); it('should return true for functions with a then method', () => { + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection const fn = Object.assign(() => {}, { then: () => {} }); expect(isPromiseLike(fn)).toBe(true); }); it('should return false when then is not a function', () => { + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection expect(isPromiseLike({ then: 'not-a-function' })).toBe(false); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection expect(isPromiseLike({ then: null })).toBe(false); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection expect(isPromiseLike({ then: 42 })).toBe(false); }); From 7b89dfc9187251c3b80c1f9f93784e7ed0ab4b7f Mon Sep 17 00:00:00 2001 From: baxyz Date: Mon, 15 Jun 2026 19:51:12 +0000 Subject: [PATCH 30/34] =?UTF-8?q?fix(CI-CD):=20=F0=9F=90=9B=20improve=20co?= =?UTF-8?q?mments=20for=20built-in=20module=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../helpers/get-external-dependencies.helper.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/scripts/build/helpers/get-external-dependencies.helper.ts b/scripts/build/helpers/get-external-dependencies.helper.ts index 7c158fc8..7e5fba8a 100644 --- a/scripts/build/helpers/get-external-dependencies.helper.ts +++ b/scripts/build/helpers/get-external-dependencies.helper.ts @@ -39,15 +39,10 @@ export async function getExternalDependencies(categoryName: string): Promise Date: Mon, 15 Jun 2026 20:01:03 +0000 Subject: [PATCH 31/34] =?UTF-8?q?chore:=20=F0=9F=94=A7=20update=20dependen?= =?UTF-8?q?cies=20for=20babel=20packages=20-=20add=20@babel/core=20to=20pa?= =?UTF-8?q?ckage.json=20and=20pnpm-lock.yaml=20-=20update=20babel=20depend?= =?UTF-8?q?encies=20to=20version=207.29.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- pnpm-lock.yaml | 233 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 162 insertions(+), 74 deletions(-) diff --git a/package.json b/package.json index d6f7c3b9..95e15fdf 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,8 @@ "brace-expansion": ">=5.0.6", "qs": ">=6.15.2", "ws": ">=8.20.1", - "esbuild": ">=0.28.1" + "esbuild": ">=0.28.1", + "@babel/core": ">=7.29.6" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31fbbc79..cbfd17ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,7 @@ overrides: qs: '>=6.15.2' ws: '>=8.20.1' esbuild: '>=0.28.1' + '@babel/core': '>=7.29.6' importers: @@ -83,36 +84,48 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} '@babel/helper-create-class-features-plugin@7.28.6': resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.28.5': resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} @@ -121,11 +134,21 @@ packages: resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': '>=7.29.6' '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} @@ -139,7 +162,7 @@ packages: resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} @@ -161,8 +184,12 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.2': @@ -179,64 +206,72 @@ packages: resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-decorators@7.28.6': resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-typescript@7.28.6': resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-destructuring@7.28.5': resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-explicit-resource-management@7.28.6': resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-commonjs@7.28.6': resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-typescript@7.28.6': resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/preset-typescript@7.28.5': resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1963,18 +1998,24 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -1993,25 +2034,33 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.7 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/traverse': 7.29.0 semver: 6.3.1 @@ -2020,6 +2069,8 @@ snapshots: '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.29.0 @@ -2034,24 +2085,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/core': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.27.1': dependencies: '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/traverse': 7.29.0 @@ -2073,9 +2140,11 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.29.2': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 + '@babel/template': 7.29.7 '@babel/types': 7.29.7 '@babel/parser@7.29.2': @@ -2086,73 +2155,73 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -2162,6 +2231,12 @@ snapshots: '@babel/parser': 7.29.2 '@babel/types': 7.29.7 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -2174,6 +2249,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -2680,12 +2767,12 @@ snapshots: '@stryker-mutator/instrumenter@9.6.1': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.1 '@babel/parser': 7.29.2 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.7) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/util': 9.6.1 angular-html-parser: 10.4.0 From abc1cf9ca594634c2e2290022f124565ed2e5fcd Mon Sep 17 00:00:00 2001 From: baxyz Date: Tue, 16 Jun 2026 15:51:25 +0000 Subject: [PATCH 32/34] =?UTF-8?q?feat(number):=20=E2=9C=A8=20add=20extract?= =?UTF-8?q?Number=20function=20and=20related=20tests=20-=20implement=20ext?= =?UTF-8?q?ractNumber=20function=20to=20extract=20numbers=20from=20strings?= =?UTF-8?q?=20-=20add=20benchmarks=20for=20performance=20testing=20-=20cre?= =?UTF-8?q?ate=20property-based=20tests=20for=20various=20scenarios=20-=20?= =?UTF-8?q?include=20unit=20tests=20for=20edge=20cases=20and=20sign=20hand?= =?UTF-8?q?ling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helpers/number/extractNumber.bench.ts | 45 +++++++++++ helpers/number/extractNumber.spec.ts | 56 ++++++++++++++ helpers/number/extractNumber.test.ts | 77 +++++++++++++++++++ helpers/number/extractNumber.ts | 105 ++++++++++++++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 helpers/number/extractNumber.bench.ts create mode 100644 helpers/number/extractNumber.spec.ts create mode 100644 helpers/number/extractNumber.test.ts create mode 100644 helpers/number/extractNumber.ts diff --git a/helpers/number/extractNumber.bench.ts b/helpers/number/extractNumber.bench.ts new file mode 100644 index 00000000..04e5e9f9 --- /dev/null +++ b/helpers/number/extractNumber.bench.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { bench, describe } from 'vitest'; + +import { extractNumber } from './extractNumber'; + +describe('extractNumber', () => { + bench('already a number', () => { + extractNumber(42); + }); + bench('plain digits', () => { + extractNumber('111'); + }); + bench('number with unit suffix', () => { + extractNumber('16.5px'); + }); + bench('number embedded after a word', () => { + extractNumber('Wafer 10'); + }); + bench('glued "-" (auto: separator)', () => { + extractNumber('xxx-111'); + }); + bench('space-separated "-" (auto: sign)', () => { + extractNumber('xxx -111'); + }); + bench('sign: strict', () => { + extractNumber('xxx-111', { sign: 'strict' }); + }); + bench('sign: ignore', () => { + extractNumber('xxx -111', { sign: 'ignore' }); + }); + bench('scientific notation', () => { + extractNumber('1.5e-10'); + }); + bench('glued exponent suffix (auto: mantissa only)', () => { + extractNumber('1e5kg'); + }); + bench('no number found', () => { + extractNumber('no number here'); + }); +}); diff --git a/helpers/number/extractNumber.spec.ts b/helpers/number/extractNumber.spec.ts new file mode 100644 index 00000000..fa0bd5f1 --- /dev/null +++ b/helpers/number/extractNumber.spec.ts @@ -0,0 +1,56 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { extractNumber } from './extractNumber'; + +const word = fc.stringMatching(/^[a-z]{1,10}$/); +const magnitude = fc.integer({ min: 1, max: 1_000_000 }); + +describe('extractNumber — property-based', () => { + it('round-trips a plain integer, as a number or as its string form', () => { + fc.assert( + fc.property(fc.integer(), (n) => { + expect(extractNumber(n)).toBe(n); + expect(extractNumber(String(n))).toBe(n); + }), + ); + }); + + it('sign "auto": a "-" glued to preceding text is a separator, not a sign', () => { + fc.assert( + fc.property(word, magnitude, (w, n) => { + expect(extractNumber(`${w}-${n}`)).toBe(n); + }), + ); + }); + + it('sign "auto": a "-" preceded by whitespace is a minus sign', () => { + fc.assert( + fc.property(word, magnitude, (w, n) => { + expect(extractNumber(`${w} -${n}`)).toBe(-n); + }), + ); + }); + + it('sign "ignore": always returns a non-negative magnitude', () => { + fc.assert( + fc.property(fc.boolean(), magnitude, (leadingSpace, n) => { + const text = leadingSpace ? ` -${n}` : `-${n}`; + expect(extractNumber(text, { sign: 'ignore' })).toBe(n); + }), + ); + }); + + it('sign "strict": always returns a negative number when "-" precedes the digits', () => { + fc.assert( + fc.property(word, magnitude, (w, n) => { + expect(extractNumber(`${w}-${n}`, { sign: 'strict' })).toBe(-n); + }), + ); + }); +}); diff --git a/helpers/number/extractNumber.test.ts b/helpers/number/extractNumber.test.ts new file mode 100644 index 00000000..fc505590 --- /dev/null +++ b/helpers/number/extractNumber.test.ts @@ -0,0 +1,77 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { extractNumber } from './extractNumber'; + +describe('extractNumber', () => { + it('should extract a number embedded in text', () => { + expect(extractNumber('16.5px')).toBe(16.5); + expect(extractNumber('Wafer 10')).toBe(10); + expect(extractNumber('Wafer 10.')).toBe(10); + expect(extractNumber('no number here')).toBeUndefined(); + }); + + it('should pass through numbers, and reject NaN', () => { + expect(extractNumber(42)).toBe(42); + expect(extractNumber(-3.14)).toBe(-3.14); + expect(extractNumber(Number.NaN)).toBeUndefined(); + }); + + it('should return undefined for non-string, non-number values', () => { + expect(extractNumber(null)).toBeUndefined(); + expect(extractNumber(undefined)).toBeUndefined(); + expect(extractNumber(true)).toBeUndefined(); + expect(extractNumber({})).toBeUndefined(); + expect(extractNumber([])).toBeUndefined(); + }); + + it('should return the first number found in text with multiple numbers', () => { + expect(extractNumber('Wafer 10 of 20')).toBe(10); + }); + + describe('sign disambiguation (default: auto)', () => { + it('treats a glued "-" as a separator', () => { + expect(extractNumber('xxx-111')).toBe(111); + }); + + it('treats a space-separated "-" as a minus sign', () => { + expect(extractNumber('xxx -111')).toBe(-111); + }); + + it('treats a leading "-" at the start of the string as a minus sign', () => { + expect(extractNumber('-111')).toBe(-111); + }); + + it('sign: "strict" always treats "-" as a minus sign', () => { + expect(extractNumber('xxx-111', { sign: 'strict' })).toBe(-111); + }); + + it('sign: "ignore" never treats "-" as a minus sign', () => { + expect(extractNumber('xxx -111', { sign: 'ignore' })).toBe(111); + expect(extractNumber('-111', { sign: 'ignore' })).toBe(111); + }); + }); + + describe('exponent disambiguation (default: auto)', () => { + it('treats a free-standing "e" suffix as scientific notation', () => { + expect(extractNumber('1e5 mol')).toBe(100000); + expect(extractNumber('1.5e-10')).toBe(1.5e-10); + }); + + it('treats a glued "e" suffix as plain text, not an exponent', () => { + expect(extractNumber('1e5kg')).toBe(1); + }); + + it('exponent: "strict" always treats the suffix as scientific notation', () => { + expect(extractNumber('1e5kg', { exponent: 'strict' })).toBe(100000); + }); + + it('exponent: "ignore" never treats the suffix as scientific notation', () => { + expect(extractNumber('1e5 mol', { exponent: 'ignore' })).toBe(1); + }); + }); +}); diff --git a/helpers/number/extractNumber.ts b/helpers/number/extractNumber.ts new file mode 100644 index 00000000..3861ba8d --- /dev/null +++ b/helpers/number/extractNumber.ts @@ -0,0 +1,105 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Options for {@link extractNumber}. + */ +export interface ExtractNumberOptions { + /** + * How to interpret a `-` immediately before the matched digits. + * - `'auto'`: treated as a minus sign unless it is glued to a preceding letter/digit + * (e.g. `'-111'` and `'xxx -111'` → negative, but `'xxx-111'` → separator, positive). + * - `'strict'`: always treated as a minus sign. + * - `'ignore'`: never treated as a minus sign (always a separator). + * @default 'auto' + */ + sign?: 'auto' | 'strict' | 'ignore'; + + /** + * How to interpret a trailing scientific-notation suffix (`e`/`E` + digits, e.g. `1.5e-10`). + * - `'auto'`: treated as an exponent unless it is glued to a following letter/digit + * (e.g. `'1e5'` and `'1e5 mol'` → exponential, but `'1e5kg'` → mantissa only). + * - `'strict'`: always treated as an exponent. + * - `'ignore'`: never treated as an exponent (mantissa only). + * @default 'auto' + */ + exponent?: 'auto' | 'strict' | 'ignore'; +} + +const NUMBER_TOKEN = /(-)?(\d+(?:\.\d+)?)([eE][+-]?\d+)?/g; + +function isWordChar(char: string | undefined): boolean { + return char !== undefined && /[\p{L}\p{N}_]/u.test(char); +} + +/** + * Extracts the first number embedded anywhere in a string, or passes through a `number`. + * + * Unlike a plain `parseFloat`/`parseInt`, the number does not need to be at the start of + * the string: digits are searched for anywhere, so leading/trailing text (units, labels, ...) + * is ignored. A `-` before the digits and a scientific-notation suffix (`e`/`E`) are + * disambiguated with {@link ExtractNumberOptions.sign} and {@link ExtractNumberOptions.exponent}. + * + * Returns `undefined` if no number can be found. + * + * @param value - The value to extract a number from + * @param options - Options controlling sign and exponent disambiguation + * @returns The extracted number, or `undefined` if none was found + * @example + * extractNumber('16.5px') // => 16.5 + * extractNumber('Wafer 10') // => 10 + * extractNumber('xxx-111') // => 111 ('-' glued to text → separator) + * extractNumber('xxx -111') // => -111 ('-' preceded by a space → sign) + * extractNumber('-111') // => -111 ('-' at the start of the string → sign) + * extractNumber('1e5 mol') // => 100000 + * extractNumber('1e5kg') // => 1 ('e5' glued to text → mantissa only) + * extractNumber('no number') // => undefined + * extractNumber(42) // => 42 + * @since next + */ +export function extractNumber(value: unknown, options: ExtractNumberOptions = {}): number | undefined { + const { sign = 'auto', exponent = 'auto' } = options; + + if (typeof value === 'number') { + return Number.isNaN(value) ? undefined : value; + } + + if (typeof value !== 'string') { + return undefined; + } + + NUMBER_TOKEN.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = NUMBER_TOKEN.exec(value)) !== null) { + const [full, signChar, mantissa, exponentPart] = match; + const start = match.index; + + if (signChar) { + const before = value[start - 1]; + const keepSign = sign === 'strict' ? true : sign === 'ignore' ? false : !isWordChar(before); + + if (!keepSign) { + // Re-scan from right after the '-' so the digits are matched as a fresh, unsigned number. + NUMBER_TOKEN.lastIndex = start + 1; + continue; + } + } + + const keepExponent = exponentPart + ? exponent === 'strict' + ? true + : exponent === 'ignore' + ? false + : !isWordChar(value[start + full.length]) + : false; + + const numericText = (signChar ?? '') + mantissa + (keepExponent ? exponentPart : ''); + return Number(numericText); + } + + return undefined; +} From 1efc498e83fd29e1cb3442ffc8c61b43094042d9 Mon Sep 17 00:00:00 2001 From: baxyz Date: Tue, 16 Jun 2026 20:49:42 +0000 Subject: [PATCH 33/34] =?UTF-8?q?feat(CI-CD):=20=E2=9C=A8=20add=20caching?= =?UTF-8?q?=20and=20comment=20stripping=20for=20external=20dependencies=20?= =?UTF-8?q?-=20implement=20caching=20for=20source=20file=20contents=20-=20?= =?UTF-8?q?add=20function=20to=20strip=20comments=20from=20source=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build/build-website-metadata.ts | 53 +++++++++++++++++++ .../get-external-dependencies.helper.ts | 12 ++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/scripts/build/build-website-metadata.ts b/scripts/build/build-website-metadata.ts index 5ac619ab..1e3b65cb 100644 --- a/scripts/build/build-website-metadata.ts +++ b/scripts/build/build-website-metadata.ts @@ -214,6 +214,59 @@ function processSignature(sig: SignatureReflection): WebsiteSignature { }; } +// Caches source file contents so a file with multiple exported type aliases +// is only read from disk once. +const sourceFileCache = new Map(); + +function readSourceCached(path: string): string { + let src = sourceFileCache.get(path); + if (src === undefined) { + src = readFileSync(path, 'utf-8'); + sourceFileCache.set(path, src); + } + return src; +} + +/** + * Finds the index of the `;` that terminates a type alias starting at `start`, + * skipping over brackets/semicolons that appear inside string/template literals + * or comments (e.g. a string-literal type like `'{' | '}' | ';'`). + */ +function findTopLevelSemicolon(src: string, start: number): number { + let depth = 0; + let quote: string | null = null; + let inLineComment = false; + let inBlockComment = false; + + for (let i = start; i < src.length; i++) { + const ch = src[i]; + const next = src[i + 1]; + + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + if (ch === '*' && next === '/') { inBlockComment = false; i++; } + continue; + } + if (quote) { + if (ch === '\\') { i++; } + else if (ch === quote) { quote = null; } + continue; + } + + if (ch === '/' && next === '/') { inLineComment = true; i++; continue; } + if (ch === '/' && next === '*') { inBlockComment = true; i++; continue; } + if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; } + + if (ch === '{' || ch === '(' || ch === '[') depth++; + else if (ch === '}' || ch === ')' || ch === ']') depth--; + else if (ch === ';' && depth === 0) return i; + } + return src.length; +} + function processMember(child: DeclarationReflection): WebsiteFunction | undefined { const kindMap: Record = { [ReflectionKind.Function]: 'function', diff --git a/scripts/build/helpers/get-external-dependencies.helper.ts b/scripts/build/helpers/get-external-dependencies.helper.ts index 7e5fba8a..ef9ae874 100644 --- a/scripts/build/helpers/get-external-dependencies.helper.ts +++ b/scripts/build/helpers/get-external-dependencies.helper.ts @@ -5,10 +5,20 @@ */ import { readdir } from "node:fs/promises"; +import { builtinModules } from "node:module"; import { join } from "node:path"; import { DIR } from "../../constants"; import { readFileText } from "../../utils"; +/** + * Strips `/* ... *\/` and `// ...` comments from source text so that import-like + * text inside JSDoc `@example` blocks (e.g. `* import { x } from 'pkg';`) is not + * mistaken for a real import statement. + */ +function stripComments(content: string): string { + return content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); +} + /** * Analyzes the external packages used in a specific category by parsing import statements * @param categoryName - The name of the category (e.g., 'string', 'url', 'observable') @@ -25,7 +35,7 @@ export async function getExternalDependencies(categoryName: string): Promise Date: Tue, 16 Jun 2026 20:54:26 +0000 Subject: [PATCH 34/34] fix(security): override markdown-it to >=14.2.0 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- pnpm-lock.yaml | 17 +++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 95e15fdf..3032cfcd 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,8 @@ "qs": ">=6.15.2", "ws": ">=8.20.1", "esbuild": ">=0.28.1", - "@babel/core": ">=7.29.6" + "@babel/core": ">=7.29.6", + "markdown-it": ">=14.2.0" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbfd17ce..8165684c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,7 @@ overrides: ws: '>=8.20.1' esbuild: '>=0.28.1' '@babel/core': '>=7.29.6' + markdown-it: '>=14.2.0' importers: @@ -1544,8 +1545,8 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} @@ -1570,8 +1571,8 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} hasBin: true math-intrinsics@1.1.0: @@ -3298,7 +3299,7 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - linkify-it@5.0.0: + linkify-it@5.0.1: dependencies: uc.micro: 2.1.0 @@ -3326,11 +3327,11 @@ snapshots: dependencies: semver: 7.8.3 - markdown-it@14.1.1: + markdown-it@14.2.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.0 + linkify-it: 5.0.1 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -3601,7 +3602,7 @@ snapshots: dependencies: '@gerrit0/mini-shiki': 3.23.0 lunr: 2.3.9 - markdown-it: 14.1.1 + markdown-it: 14.2.0 minimatch: 10.2.5 typescript: 6.0.3 yaml: 2.8.3