Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 49 additions & 26 deletions packages/codemode/src/stdlib/value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,34 @@ export const errorBrandName = (value: unknown): string | undefined =>

export const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true)

type SandboxStringFormatter = readonly [
ctor: new (...args: never[]) => unknown,
format: (value: never) => string,
]

const sandboxStringFormatters: readonly SandboxStringFormatter[] = [
[SandboxDate, (value: SandboxDate) => (Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date")],
[SandboxRegExp, (value: SandboxRegExp) => `/${value.regex.source}/${value.regex.flags}`],
[SandboxMap, () => "[object Map]"],
[SandboxSet, () => "[object Set]"],
[SandboxURL, (value: SandboxURL) => value.url.href],
[SandboxURLSearchParams, (value: SandboxURLSearchParams) => value.params.toString()],
]

const coerceArrayToString = (value: unknown[]): string =>
value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")

export const coerceToString = (value: unknown): string => {
if (value === null) return "null"
if (value === undefined) return "undefined"
if (value instanceof SandboxDate)
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof SandboxMap) return "[object Map]"
if (value instanceof SandboxSet) return "[object Set]"
if (value instanceof SandboxURL) return value.url.href
if (value instanceof SandboxURLSearchParams) return value.params.toString()

const formatter = sandboxStringFormatters.find(([ctor]) => value instanceof ctor)
if (formatter) return formatter[1](value as never)

if (typeof value === "object") {
return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
: "[object Object]"
return Array.isArray(value) ? coerceArrayToString(value) : "[object Object]"
}

return String(value)
}

Expand All @@ -51,27 +64,37 @@ export const coerceToNumber = (value: unknown): number => {
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
}

const sandboxCoercions: Record<string, (raw: unknown) => unknown> = {
Number: (raw) => coerceToNumber(raw),
String: (raw) => coerceToString(raw),
parseInt: (raw) => parseInt(coerceToString(raw)),
}

const valueCoercions: Record<string, (value: unknown) => unknown> = {
Number: (value) => coerceToNumber(value),
Boolean: (value) => Boolean(value),
parseFloat: (value) => parseFloat(coerceToString(value)),
}

function coerceParseInt(value: unknown, args: Array<unknown>, node: AstNode): number {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
}
return parseInt(coerceToString(value), radix as number | undefined)
}

export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
const raw = args[0]

if (isSandboxValue(raw)) {
if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw)
if (ref.name === "String") return coerceToString(raw)
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
return parseFloat(coerceToString(raw))
return sandboxCoercions[ref.name]?.(raw) ?? parseFloat(coerceToString(raw))
}
const value = boundedData(args[0], `${ref.name} input`)
if (ref.name === "Number") return coerceToNumber(value)
if (ref.name === "Boolean") return Boolean(value)
if (ref.name === "parseInt") {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
}
return parseInt(coerceToString(value), radix)
}
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
return coerceToString(value)

const value = boundedData(raw, `${ref.name} input`)
if (ref.name === "parseInt") return coerceParseInt(value, args, node)
return valueCoercions[ref.name]?.(value) ?? coerceToString(value)
}
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js"
Expand Down
13 changes: 13 additions & 0 deletions packages/codemode/test/stdlib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,3 +713,16 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }])
})
})

describe("array-to-string coercion", () => {
test("String() on an array joins elements with commas, treating null/undefined as empty", async () => {
expect(await value(`return String([1, null, undefined, 2])`)).toBe("1,,,2")
})
})

describe("parseInt radix validation", () => {
test("rejects a non-numeric radix", async () => {
const err = await error(`return parseInt("10", "x")`)
expect(err.message).toContain("parseInt expects a numeric radix.")
})
})
Loading