diff --git a/AGENTS.md b/AGENTS.md index 482ef2b9e..6977eea47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ This repo contains multiple packages, but **`packages/coding-agent/`** is the pr | `packages/tui` | Terminal UI library with differential rendering | | `packages/natives` | Bindings for native text/image/grep operations | | `packages/stats` | Local observability dashboard (`omp stats`) | +| `packages/omptype` | ArkType-compatible schema validation with a lazy JIT runtime | | `packages/utils` | Shared utilities (logger, streams, temp files) | | `crates/pi-natives` | Rust crate for performance-critical text/grep ops | diff --git a/README.md b/README.md index fffd522d8..43e07feb2 100644 --- a/README.md +++ b/README.md @@ -606,6 +606,7 @@ For architecture and contribution guidelines, see [packages/coding-agent/DEVELOP | **[@oh-my-pi/pi-tui](packages/tui)** | Terminal UI library with differential rendering | | **[@oh-my-pi/pi-natives](packages/natives)** | N-API bindings for grep, shell, image, text, syntax highlighting, and more | | **[@oh-my-pi/omp-stats](packages/stats)** | Local observability dashboard for AI usage statistics | +| **[@oh-my-pi/omptype](packages/omptype)** | ArkType-compatible schema validation with lazy JIT compilation | | **[@oh-my-pi/pi-utils](packages/utils)** | Shared utilities (logging, streams, dirs/env/process helpers) | | **[@oh-my-pi/pi-wire](packages/wire)** | Shared collab live-session protocol types and relay constants | | **[@oh-my-pi/hashline](packages/hashline)** | Line-anchored patch language and applier behind the `edit` tool | diff --git a/biome.json b/biome.json index b69c3a464..22c20352f 100644 --- a/biome.json +++ b/biome.json @@ -54,6 +54,7 @@ "packages/*/src/**/*.ts", "packages/*/src/**/*.tsx", "packages/*/test/**/*.ts", + "packages/*/bench/**/*.ts", "packages/*/examples/**/*.ts", "packages/*/scripts/**/*.ts", "packages/*/*.ts", diff --git a/bun.lock b/bun.lock index e16af575b..226515bb8 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@opentelemetry/api": "catalog:", }, "devDependencies": { + "@oh-my-pi/omptype": "catalog:", "@opentelemetry/context-async-hooks": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", "@types/bun": "catalog:", @@ -42,10 +43,10 @@ "version": "17.2.6", "dependencies": { "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/omptype": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "@oh-my-pi/pi-wire": "catalog:", - "arktype": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -66,8 +67,8 @@ "version": "17.2.6", "dependencies": { "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/omptype": "catalog:", "@oh-my-pi/pi-utils": "catalog:", - "arktype": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -87,6 +88,7 @@ "@mozilla/readability": "catalog:", "@oh-my-pi/hashline": "catalog:", "@oh-my-pi/omp-stats": "catalog:", + "@oh-my-pi/omptype": "catalog:", "@oh-my-pi/pi-agent-core": "catalog:", "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -110,7 +112,6 @@ "@puppeteer/browsers": "catalog:", "@types/turndown": "catalog:", "@xterm/headless": "catalog:", - "arktype": "catalog:", "chalk": "catalog:", "fast-xml-parser": "catalog:", "handlebars": "catalog:", @@ -228,6 +229,16 @@ "@types/bun": "catalog:", }, }, + "packages/omptype": { + "name": "@oh-my-pi/omptype", + "version": "17.2.6", + "devDependencies": { + "@sinclair/typebox": "^0.34.0", + "@types/bun": "catalog:", + "arktype": "2.2.3", + "zod": "catalog:", + }, + }, "packages/snapcompact": { "name": "@oh-my-pi/snapcompact", "version": "17.2.6", @@ -381,6 +392,7 @@ "@napi-rs/cli": "3.7.2", "@oh-my-pi/hashline": "17.2.6", "@oh-my-pi/omp-stats": "17.2.6", + "@oh-my-pi/omptype": "17.2.6", "@oh-my-pi/pi-agent-core": "17.2.6", "@oh-my-pi/pi-ai": "17.2.6", "@oh-my-pi/pi-catalog": "17.2.6", @@ -413,7 +425,6 @@ "@types/turndown": "5.0.6", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@xterm/headless": "^6.0.0", - "arktype": "2.2.3", "chalk": "^5.6.2", "chart.js": "^4.5.1", "date-fns": "^4.4.0", @@ -788,6 +799,8 @@ "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@workspace:packages/stats"], + "@oh-my-pi/omptype": ["@oh-my-pi/omptype@workspace:packages/omptype"], + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@workspace:packages/agent"], "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@workspace:packages/ai"], @@ -902,6 +915,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], diff --git a/docs/arktype-guide.md b/docs/arktype-guide.md deleted file mode 100644 index ea7f430a9..000000000 --- a/docs/arktype-guide.md +++ /dev/null @@ -1,169 +0,0 @@ -# ArkType Guide (for migrating Zod → ArkType in this repo) - -Pinned to **arktype 2.2.3** (workspace catalog). Author types with -`import { type } from "arktype"`. - -> **Scope rule (READ FIRST).** Zod stays supported at the **external boundary** — `Tool.parameters` -> accepts Zod _or_ ArkType _or_ JSON Schema, and the public `pi.zod` extension API + the Zod-backed -> `typebox` shim are untouched. Migrate **internal** schemas to ArkType. If a file genuinely cannot be -> expressed cleanly in ArkType (see "Resilient parsing" below) and it parses an external/untrusted -> payload, it MAY stay on Zod — say so in your report rather than shipping broken ArkType. - -## The detection contract (don't break it) - -`packages/ai/src/utils/schema/wire.ts` distinguishes the three schema kinds: - -- **ArkType** = a _callable function_ with `.toJsonSchema` and `.assert` methods (`isArkSchema`). -- **Zod** = a non-callable object carrying `_zod` + `.parse` (`isZodSchema`). -- **JSON Schema** = a plain object. - -So an ArkType `Type` is a function. NEVER detect it via `$`/`_arktype`/`__arktype` markers — those -don't exist. `isArkSchema`, `arkToWireSchema`, `isZodSchema`, `zodToWireSchema` all remain exported. - -At the provider boundary, `toolWireSchema()` calls ArkType's -`toJsonSchema({ target: "draft-2020-12", fallback: ctx => ctx.base })`, drops -`$schema`, prunes the unconstrained branch emitted for `T | undefined`, and -closes every declared object with `additionalProperties: false`. Predicates and -morphs therefore validate locally but degrade to their base schema on the wire. -A `T | undefined` property remains required: use an optional `?` key when the -model may omit it. - -Runtime validation and wire emission deliberately differ for undeclared keys: -a plain ArkType object preserves extras during local validation, while -`toolWireSchema()` closes its declared object for provider generation. -`"+": "reject"` rejects locally, and the shared tool-validation pipeline strips -root and nested extras before dispatch. - -## Core translation table (Zod → ArkType) - -| Zod | ArkType | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `z.object({ a: ... })` | `type({ a: ... })` | -| `z.string()` / `z.number()` / `z.boolean()` | `"string"` / `"number"` / `"boolean"` | -| `z.number().int()` | `"number.integer"` | -| `z.literal("x")` | `"'x'"` ; `z.literal(5)` → `"5"` | -| `z.enum(["a","b"])` (static) | `"'a' \| 'b'"` | -| `z.enum(RUNTIME_ARRAY)` (dynamic) | `type.enumerated(...RUNTIME_ARRAY)` — NOT `type(arr.join("\|"))` | -| `z.array(z.string())` | `"string[]"` | -| `z.array(Item)` (Item is a `type`) | `Item.array()` | -| `z.union([A,B])` | `A.or(B)` or `"a \| b"` | -| `z.record(z.string(), z.number())` | `type({ "[string]": "number" })` — use the real value type, NOT `"unknown"` unless it was `z.unknown()` | -| `z.unknown()` / `z.any()` | `"unknown"` | -| `z.null()` | `"null"` | -| `z.nullable(X)` | `X.or("null")` or `"X \| null"` | -| field `.optional()` | optional **key**: `{ "a?": "string" }` (NOT a value method) | -| string length `.min(n)`/`.max(n)` | `"string >= n"` / `"string <= n"` / `"1 <= string <= 10"` | -| number `.min/.max/.gt/.lt` | `"number >= n"` / `"number > n"` / `"1 <= number <= 10"` | -| dynamic bound (runtime var) | chain methods: `type("string").atLeastLength(1).atMostLength(MAX)` — NOT a template string | -| `.describe("d")` | `.describe("d")` (emits JSON Schema `description`) | -| `.strict()` (reject extras) | add key `"+": "reject"`: `type({ "+": "reject", ... })` | -| `.strip()` (drop extras — Zod default) | add key `"+": "delete"` | -| `.passthrough()` / `.loose()` | drop it (ArkType keeps undeclared keys by default) | -| `.refine(fn, msg)` | `.narrow((d, ctx) => fn(d) \|\| ctx.mustBe(""))` | -| `z.infer` | `typeof S.infer` | -| `z.input` | `typeof S.inferIn` | - -## FOOTGUNS (these caused real breakage — avoid them) - -1. **Never put `.default()` on an optional `?` key.** `z.X.default(v).optional()` in Zod is - **output-optional** (default applied in code via `?? `) → translate to an **optional key, no - default**: `"limit?": "number"`. Only `z.X.default(v)` _without_ `.optional()` (output-required) - becomes `field: type("number").default(v)` (key has NO `?`). -2. **`.default()` only works as an object-property value.** `type("number = 0")` standalone throws — - use it inline (`type({ count: "number = 0" })`) or `.default()` on a non-optional key. -3. **A described literal union emits `anyOf` of `const`, not `enum`.** That is correct and validates - identically; assert semantic wire properties (`description`, required, `additionalProperties`), not - the exact `enum` vs `anyOf` shape. -4. **`type()` needs a statically-known definition.** A runtime-built string (`type(arr.join("|"))`, - `type(\`1 <= string <= ${MAX}\`)`) fails TS. Use `type.enumerated(...)` / chain methods instead. -5. **Integer ranges:** `"1 <= number.integer <= 3600"` (NOT `"number.integer >= 1 <= 3600"`). -6. **`toolWireSchema()` removes `$schema` automatically.** If you call - `toJsonSchema()` directly for some other boundary, strip its `$schema` - metadata there. - -## Validating with a schema (replacing `.parse` / `.safeParse`) - -ArkType `Type` is **invoked** to validate; failure returns an `ArkErrors` instance: - -```ts -import { type } from "arktype"; -const out = schema(value); -if (out instanceof type.errors) { - // out.summary -> human message; out.map(e => `${e.path}: ${e.message}`) - throw new Error(out.summary); -} -// else `out` is the validated/morphed value -``` - -- `.parse(x)` → `const out = schema(x); if (out instanceof type.errors) throw new Error(out.summary); use out;` -- `.safeParse(x).success` → `!(schema(x) instanceof type.errors)` -- NEVER use `.allows()` for tool validation — it skips morphs/defaults/narrows. -- `.infer` (output) and `.inferIn` (input) are inference-only properties (no runtime value). - -## Advanced - -### Scopes (reusable aliases / mutually-referential schemas) - -Replace a cluster of cross-referencing Zod schemas with a scope, then `.export()` to a module: - -```ts -import { scope } from "arktype"; -const myScope = scope({ - inner: { id: "string" }, - outer: { inner: "inner", tags: "string[]" }, -}); -const m = myScope.export(); // Module — m.outer, m.inner are Type instances -``` - -Use `.export()` — NOT `.compile()` (that method does not exist on a Scope). - -### Morphs / transforms (replacing `.transform()`) - -```ts -const n = type("string").pipe((s) => Number.parseInt(s)); // validate then transform -const o = type("string").to("number.integer"); // .to(def) == .pipe(type(def)) -``` - -### narrow (cross-field / post-validation predicate, replacing `.refine`) - -`narrow` runs AFTER all validators/morphs (output side). `ctx.mustBe("")` returns `false` -and records `must be `: - -```ts -type({ action: "string", "body?": "string" }).narrow( - (p, ctx) => - p.action === "delete" || - p.body !== undefined || - ctx.mustBe("a body unless deleting"), -); -``` - -### Resilient parsing (replacing Zod `.catch(fallback)`) - -ArkType has **no built-in `.catch()`**. For "parse, else fallback", wrap the unsafe work in a morph: - -```ts -const resilient = type("unknown").pipe((raw) => { - const out = innerSchema(raw); - return out instanceof type.errors ? FALLBACK : out; // never throws -}); -``` - -For "missing → default", use the `=` default syntax (`"number = 5"`). If a parser relies heavily on -per-field `.catch()` over an untrusted external payload and the morph rewrite gets unwieldy, that file -is a candidate to **stay on Zod** (external-boundary exception) — note it in your report. - -### Defaults recap - -- `type({ count: "number = 0", flag: "boolean = false" })` — inline, output-required, wire `default`. -- `type({ x: type("number").describe("d").default(0) })` — `.default()` on a NON-optional key when you - also need `.describe()`. - -## When you finish a file - -- Replace `import { z } from "zod/v4"` with `import { type } from "arktype"` (keep `z` only if still used). -- Preserve every `.describe()` string and field optionality exactly. -- Convert every `.parse`/`.safeParse` call site in the file. -- Run the focused tests that exercise the migrated schema and the affected package's type check. -- Report any `.strict`→`"+"`, `.refine`→`.narrow`, `.catch`→morph conversion, - and any file intentionally left on Zod with the reason. diff --git a/docs/omptype-guide.md b/docs/omptype-guide.md new file mode 100644 index 000000000..9ddd7f4ae --- /dev/null +++ b/docs/omptype-guide.md @@ -0,0 +1,109 @@ +# omptype Guide (schema authoring in this repo) + +Internal schemas use **`@oh-my-pi/omptype`** — an ArkType-compatible validator +with a lazy JIT runtime (`packages/omptype`). Author types with +`import { type } from "@oh-my-pi/omptype"`. + +> **Scope rule.** Zod stays supported at the **external boundary** — `Tool.parameters` +> accepts Zod _or_ omptype _or_ JSON Schema, and the public `pi.zod` extension API is +> untouched. Internal schemas use omptype. + +## Why omptype (perf contract) + +- `type()` construction is ~100x cheaper than arktype (no eager codegen, no node interning). +- The first two calls run an interpreter; the third call JIT-compiles a specialized + validator via `new Function`. Hot-path validation is tens of nanoseconds; failures + allocate one small error object with lazy message building. +- There is no `jitless` switch and no `scope()` — lazy JIT removed the startup tax + those existed to dodge. Import `type` directly. + +## The detection contract (don't break it) + +`packages/ai/src/utils/schema/wire.ts` distinguishes the three schema kinds: + +- **omptype** = a _callable function_ with `.toJsonSchema` and `.assert` methods (`isArkSchema`). +- **Zod** = a non-callable object carrying `_zod` + `.parse` (`isZodSchema`). +- **JSON Schema** = a plain object. + +At the provider boundary, `toolWireSchema()` calls `toJsonSchema()`, prunes +`T | undefined` branches, and closes declared objects with +`additionalProperties: false`. Predicates (`.narrow`) and morphs (`.pipe`) +validate locally but degrade to their base schema on the wire. + +## Definition language (arktype-compatible subset) + +| Construct | Form | +| -------------------------- | ----------------------------------------------------------------- | +| Primitives | `"string"`, `"number"`, `"boolean"`, `"null"`, `"undefined"`, `"unknown"`, `"object"`, `"bigint"` | +| Integer | `"number.integer"` | +| URL string | `"string.url"` | +| Literals | `"'x'"`, `"5"`, `"true"` | +| Unions | `"'a' \| 'b'"`, `"string \| null"` | +| Arrays | `"string[]"`, `"(string \| number)[]"`, `[def, "[]"]` | +| Bounds | `"number >= 0"`, `"0 < number <= 3600"`, `"1 <= string <= 10"` | +| Optional key | `{ "limit?": "number" }` or value-suffix `{ limit: "number?" }` | +| Defaults | `{ count: "number = 10" }`, `type("string[]").default(() => [])` | +| Undeclared keys | `"+": "reject"` (fail) / `"+": "delete"` (strip) / default keep | +| Records | `{ "[string]": "number" }` — NOT `"Record"` | +| Runtime enums | `type.enumerated(...RUNTIME_ARRAY)` | +| Runtime-built object defs | `type.raw({...})` (returns `BaseType`) | +| Keyword statics | `type.number.atLeast(5).atMost(300)`, `type.string` | + +## Validating (same as arktype) + +```ts +import { type } from "@oh-my-pi/omptype"; +const out = schema(value); +if (out instanceof type.errors) { + // out.summary → human message; entries have .path (array) and .problem + throw new Error(out.summary); +} +// `out` is the validated/morphed value (defaults filled, extras stripped) +``` + +- Failure returns an `OmpErrors` (array of `OmpError`); `type.errors === OmpErrors`. +- Validation is fast-fail: one error entry per failure. +- Morphs never mutate the input; when defaults/`"+": "delete"`/pipes apply, a fresh + object is returned. +- NEVER use `.allows()` for tool validation — it skips morphs/defaults/pipes. +- `.infer` / `.inferIn` are inference-only properties. +- Definition mistakes (bad DSL, illegal composition) throw `OmpTypeError` at + `type()` time. + +## Methods + +`.describe(d)`, `.default(v | () => v)`, `.or(TypeOrStringDef)`, `.and(Type)`, +`.array()`, `.atLeastLength(n)` / `.atMostLength(n)` (string/array), +`.atLeast(n)` / `.atMost(n)` (number), `.pipe(fn)`, `.narrow(fn)` (with +`ctx.mustBe("...")`), `.allows(v)`, `.assert(v)`, `.toJsonSchema()`. + +Note on `.or()` typing: schema and string operands infer precisely; +object-literal operands degrade — wrap them with `type({...})` first. + +## Zod → omptype translation (for new code) + +| Zod | omptype | +| ---------------------------------- | ----------------------------------------------------------- | +| `z.object({ a: ... })` | `type({ a: ... })` | +| `z.enum(["a","b"])` (static) | `"'a' \| 'b'"` | +| `z.enum(RUNTIME_ARRAY)` | `type.enumerated(...RUNTIME_ARRAY)` | +| `z.record(z.string(), z.number())` | `type({ "[string]": "number" })` | +| `.optional()` | optional key `{ "a?": "string" }` | +| `.strict()` / `.strip()` | `"+": "reject"` / `"+": "delete"` | +| `.refine(fn, msg)` | `.narrow((d, ctx) => fn(d) \|\| ctx.mustBe(""))` | +| `.transform(fn)` | `.pipe(fn)` | +| `.catch(fallback)` | `type("unknown").pipe(raw => { const out = inner(raw); return out instanceof type.errors ? FALLBACK : out; })` | +| `z.infer` | `typeof S.infer` | + +## Adapters + +For TypeBox-style or Zod-style authoring backed by the omptype runtime: + +```ts +import { Type, type Static } from "@oh-my-pi/omptype/typebox"; +import { z } from "@oh-my-pi/omptype/zod"; +``` + +Both produce real omptype schemas (JIT validation, `toJsonSchema`, wire +detection). Use them for extension-facing surfaces; internal code authors +the string DSL directly. diff --git a/docs/user-facing-packages.md b/docs/user-facing-packages.md index 924be4e87..7150b09bd 100644 --- a/docs/user-facing-packages.md +++ b/docs/user-facing-packages.md @@ -46,6 +46,16 @@ Sources: [`packages/stats/README.md`](../packages/stats/README.md), [`packages/s - Outputs: dashboard metrics and API endpoints including `/api/stats`, `/api/stats/models`, `/api/stats/folders`, `/api/stats/timeseries`, and `/api/sync`. - Side effects/limits: syncs session files before output; long-running dashboard stops on `Ctrl+C` and closes the stats database. +### `packages/omptype` — schema validation library + +Sources: [`packages/omptype/README.md`](../packages/omptype/README.md), [`packages/omptype/package.json`](../packages/omptype/package.json), and the repository [omptype authoring guide](./omptype-guide.md). + +- Package: public `@oh-my-pi/omptype`; install with `bun add @oh-my-pi/omptype`; requires Bun 1.3.14 or newer. +- Feature: callable ArkType-compatible schemas with cheap interpreted startup, lazy hot-path compilation, validation errors, defaults and morphs, and JSON Schema emission. +- Public surfaces: `@oh-my-pi/omptype` for native authoring, `@oh-my-pi/omptype/typebox` and `/zod` for compatibility builders, and `/ark` for the alias-free ArkType compatibility facade. +- Runtime behavior: schema calls return the validated value or `type.errors`; `.assert()` returns the value or throws; `.allows()` performs a boolean check. +- Limits: this is an intentionally focused compatibility surface rather than a complete implementation of every ArkType, TypeBox, or Zod API. + ### `packages/typescript-edit-benchmark` — TypeScript edit fixture engine Sources: [`packages/typescript-edit-benchmark/package.json`](../packages/typescript-edit-benchmark/package.json), [`packages/typescript-edit-benchmark/src/generate.ts`](../packages/typescript-edit-benchmark/src/generate.ts), [`packages/typescript-edit-benchmark/src/tasks.ts`](../packages/typescript-edit-benchmark/src/tasks.ts), [`packages/typescript-edit-benchmark/src/verify.ts`](../packages/typescript-edit-benchmark/src/verify.ts), and the runner in [`packages/metaharness/adapters/edit/cli.ts`](../packages/metaharness/adapters/edit/cli.ts). diff --git a/package.json b/package.json index 5bafac973..be16605e5 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@napi-rs/cli": "3.7.2", "@oh-my-pi/hashline": "17.2.6", "@oh-my-pi/omp-stats": "17.2.6", + "@oh-my-pi/omptype": "17.2.6", "@oh-my-pi/pi-agent-core": "17.2.6", "@oh-my-pi/pi-ai": "17.2.6", "@oh-my-pi/pi-catalog": "17.2.6", @@ -60,7 +61,6 @@ "@types/turndown": "5.0.6", "@typescript/native-preview": "7.0.0-dev.20260707.2", "@xterm/headless": "^6.0.0", - "arktype": "2.2.3", "chalk": "^5.6.2", "chart.js": "^4.5.1", "date-fns": "^4.4.0", @@ -117,7 +117,7 @@ "build:native": "bun --cwd=packages/natives run build", "test": "bun scripts/ci-test-ts.ts local", "test:ts": "bun scripts/ci-test-ts.ts local-ts", - "test:scripts": "bun test scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts", + "test:scripts": "bun test scripts/ci-release-build-binaries.test.ts scripts/musl-release.test.ts scripts/ci-release-publish.test.ts", "test:rs": "bun scripts/run-rs-task.ts test:rs", "check": "bun run --parallel check:ts check:rs", "check:ts": "bun run check:tools && bun run --workspaces --if-present check", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 844d6f388..8c65af4a9 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Replaced arktype with `@oh-my-pi/omptype` in tests and test utilities. + ## [17.2.6] - 2026-08-03 ### Fixed diff --git a/packages/agent/package.json b/packages/agent/package.json index 91437ea6d..9c228c727 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -44,6 +44,7 @@ "@opentelemetry/api": "catalog:" }, "devDependencies": { + "@oh-my-pi/omptype": "catalog:", "@opentelemetry/context-async-hooks": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", "@types/bun": "catalog:" diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index bb6502863..80a544832 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { agentLoop, @@ -20,7 +21,6 @@ import type { AssistantMessage, AssistantMessageEvent, Context, Message, ToolRes import { createMockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; -import { type } from "arktype"; import { createAssistantMessage, createUserMessage } from "./helpers"; declare module "@oh-my-pi/pi-agent-core/types" { diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index 027747f03..06a501a48 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context"; import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types"; import type { Message, Tool, ToolExample } from "@oh-my-pi/pi-ai"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; -import { type } from "arktype"; // --------------------------------------------------------------------------- // Helpers diff --git a/packages/agent/test/normalize-tools-prune.test.ts b/packages/agent/test/normalize-tools-prune.test.ts index 41f80e398..add1be045 100644 --- a/packages/agent/test/normalize-tools-prune.test.ts +++ b/packages/agent/test/normalize-tools-prune.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { normalizeTools } from "@oh-my-pi/pi-agent-core/agent-loop"; import type { AgentTool } from "@oh-my-pi/pi-agent-core/types"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; -import { type } from "arktype"; const toolSchema = type({ path: type("string").describe("where to read"), diff --git a/packages/agent/test/pause-gate.test.ts b/packages/agent/test/pause-gate.test.ts index 1ff94c72c..e534c4ac9 100644 --- a/packages/agent/test/pause-gate.test.ts +++ b/packages/agent/test/pause-gate.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { agentLoop, agentPauseGate } from "@oh-my-pi/pi-agent-core"; import type { AgentContext, AgentLoopConfig, AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core/types"; import type { Message } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; -import { type } from "arktype"; import { createUserMessage } from "./helpers"; function identityConverter(messages: AgentMessage[]): Message[] { diff --git a/packages/agent/test/prompt-tools-loop.test.ts b/packages/agent/test/prompt-tools-loop.test.ts index ef939df8c..46a2b7a3d 100644 --- a/packages/agent/test/prompt-tools-loop.test.ts +++ b/packages/agent/test/prompt-tools-loop.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { agentLoop } from "@oh-my-pi/pi-agent-core/agent-loop"; import type { AgentContext, AgentLoopConfig, AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core/types"; import type { AssistantMessage, Context, Message, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; -import { type } from "arktype"; import { createUserMessage } from "./helpers"; function identityConverter(messages: AgentMessage[]): Message[] { diff --git a/packages/agent/test/soft-tool-requirement.test.ts b/packages/agent/test/soft-tool-requirement.test.ts index 65e3e2261..5ae7c1561 100644 --- a/packages/agent/test/soft-tool-requirement.test.ts +++ b/packages/agent/test/soft-tool-requirement.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { agentLoop } from "@oh-my-pi/pi-agent-core/agent-loop"; import type { AgentContext, @@ -9,7 +10,6 @@ import type { } from "@oh-my-pi/pi-agent-core/types"; import type { Message, ToolChoice } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; -import { type } from "arktype"; import { createUserMessage } from "./helpers"; function identityConverter(messages: AgentMessage[]): Message[] { diff --git a/packages/agent/test/utils/calculate.ts b/packages/agent/test/utils/calculate.ts index 589cfd802..2c58f36ce 100644 --- a/packages/agent/test/utils/calculate.ts +++ b/packages/agent/test/utils/calculate.ts @@ -1,5 +1,5 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core/types"; -import { type } from "arktype"; export interface CalculateResult extends AgentToolResult { content: Array<{ type: "text"; text: string }>; diff --git a/packages/agent/test/utils/get-current-time.ts b/packages/agent/test/utils/get-current-time.ts index 6e4a1401d..70eecdb38 100644 --- a/packages/agent/test/utils/get-current-time.ts +++ b/packages/agent/test/utils/get-current-time.ts @@ -1,5 +1,5 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core/types"; -import { type } from "arktype"; export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {} diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 73ad730be..5a1d9d45c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Replaced arktype with `@oh-my-pi/omptype`, an ArkType-compatible validator with lazy JIT compilation: ~100x faster schema construction and 60-100x faster hot-path validation. `packages/ai` re-exports `type`/`Type` from omptype; the wire schema detection contract (`isArkSchema`) is unchanged. + ### Fixed - Fixed OpenAI-Codex (ChatGPT OAuth) requests failing with `Unsupported service_tier: auto` on default/legacy sessions. `shouldSendServiceTier` no longer forwards `auto` on the wire — it is OpenAI's implicit default, so omitting `service_tier` is equivalent, and the Codex endpoint rejects an explicit `auto`. Explicit `default`/`flex`/`scale`/`priority` are unaffected ([#7517](https://github.com/can1357/oh-my-pi/issues/7517)). diff --git a/packages/ai/package.json b/packages/ai/package.json index 5f07b826f..66760e447 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -38,10 +38,10 @@ }, "dependencies": { "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/omptype": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", "@oh-my-pi/pi-utils": "catalog:", "@oh-my-pi/pi-wire": "catalog:", - "arktype": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/ai/src/auth-broker/client.ts b/packages/ai/src/auth-broker/client.ts index c77b69be0..61a71b93c 100644 --- a/packages/ai/src/auth-broker/client.ts +++ b/packages/ai/src/auth-broker/client.ts @@ -5,8 +5,9 @@ * `omp auth-broker status` (liveness checks). All endpoints except * `/v1/healthz` require a bearer token. */ + +import { type } from "@oh-my-pi/omptype"; import { readSseEvents } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import type { AuthCredential, DisabledCredentialSummary } from "../auth-storage"; import type { ClientUsageReportRequest, diff --git a/packages/ai/src/auth-broker/server.ts b/packages/ai/src/auth-broker/server.ts index 29b4ee6ec..d6f85580e 100644 --- a/packages/ai/src/auth-broker/server.ts +++ b/packages/ai/src/auth-broker/server.ts @@ -9,8 +9,9 @@ * Transport security is delegated to the operator (Tailscale / Wireguard); * the server only checks a bearer token against an allow-list per request. */ + +import { type Type, type } from "@oh-my-pi/omptype"; import { logger } from "@oh-my-pi/pi-utils"; -import { type Type, type } from "arktype"; import type { AuthStorage, StoredCredentialBlock } from "../auth-storage"; import { parseBind } from "../utils/parse-bind"; import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher"; diff --git a/packages/ai/src/auth-broker/wire-schema-resource.ts b/packages/ai/src/auth-broker/wire-schema-resource.ts index b7bde16e1..62fcd5711 100644 --- a/packages/ai/src/auth-broker/wire-schema-resource.ts +++ b/packages/ai/src/auth-broker/wire-schema-resource.ts @@ -12,7 +12,7 @@ * exception (standard type keeps extra keys): it preserves provider-specific extension fields so * they round-trip through the broker instead of being dropped (see below). */ -import { scope, type Type } from "arktype"; +import { type Type, type } from "@oh-my-pi/omptype"; import { type ApiKeyCredential, type AuthCredential, @@ -84,11 +84,6 @@ export interface AuthBrokerWireSchemas { } function buildAuthBrokerWireSchemas(): AuthBrokerWireSchemas { - // Wire schemas validate only a handful of times per process, so ArkType's - // definition-time JIT codegen is startup tax. A local jitless scope skips - // that codegen and uses interpreted traversal; correctness is unchanged. - const { type } = scope({}, { jitless: true }); - // ─── Credential payloads ─────────────────────────────────────────────────── /** Real OAuth credential (broker-side) — refresh token is the actual upstream value. */ diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 3d2d637b8..82e7019d0 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,4 +1,4 @@ -export { type Type, type } from "arktype"; +export { type Type, type } from "@oh-my-pi/omptype"; export { type ZodType, z } from "zod/v4"; export * from "./api-registry"; export type * from "./auth-broker"; diff --git a/packages/ai/src/providers/anthropic-messages-server-schema.ts b/packages/ai/src/providers/anthropic-messages-server-schema.ts index 0835883b0..20f3667a6 100644 --- a/packages/ai/src/providers/anthropic-messages-server-schema.ts +++ b/packages/ai/src/providers/anthropic-messages-server-schema.ts @@ -4,7 +4,7 @@ * and options. */ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { ContentBlockParam, ImageBlockParam, diff --git a/packages/ai/src/providers/anthropic-messages-server.ts b/packages/ai/src/providers/anthropic-messages-server.ts index 97649ad5c..6bb79d992 100644 --- a/packages/ai/src/providers/anthropic-messages-server.ts +++ b/packages/ai/src/providers/anthropic-messages-server.ts @@ -1,6 +1,6 @@ +import { type } from "@oh-my-pi/omptype"; import { Effort } from "@oh-my-pi/pi-catalog/effort"; import { logger } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http"; import * as AIError from "../error"; import type { diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index b1fc40508..8631e9900 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -5,6 +5,7 @@ */ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { scheduler } from "node:timers/promises"; +import { type } from "@oh-my-pi/omptype"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { ANTIGRAVITY_SYSTEM_INSTRUCTION, @@ -13,7 +14,6 @@ import { getGeminiCliHeaders, } from "@oh-my-pi/pi-catalog/wire/gemini-headers"; import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import * as AIError from "../error"; import type { Api, diff --git a/packages/ai/src/providers/openai-chat-server-schema.ts b/packages/ai/src/providers/openai-chat-server-schema.ts index e6007a829..a1375d8c3 100644 --- a/packages/ai/src/providers/openai-chat-server-schema.ts +++ b/packages/ai/src/providers/openai-chat-server-schema.ts @@ -7,7 +7,7 @@ * `stream_options.include_obfuscation`) — does not trip 400s on shapes we simply ignore. */ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { ChatCompletionContentPart, ChatCompletionCreateParams, diff --git a/packages/ai/src/providers/openai-chat-server.ts b/packages/ai/src/providers/openai-chat-server.ts index 978a8e4e4..4a2a91bc3 100644 --- a/packages/ai/src/providers/openai-chat-server.ts +++ b/packages/ai/src/providers/openai-chat-server.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { resolvePromptCacheKey } from "../auth-gateway/http"; /** * Parsed inbound OpenAI chat-completions request, ready to feed into pi-ai diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index daa51e802..8c27fe700 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -1,5 +1,6 @@ import * as os from "node:os"; import { scheduler } from "node:timers/promises"; +import { type } from "@oh-my-pi/omptype"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { CODEX_BASE_URL, @@ -19,7 +20,6 @@ import { readSseJson, structuredCloneJSON, } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import packageJson from "../../package.json" with { type: "json" }; import * as AIError from "../error"; import { getEnvApiKey, isOfficialCodexApiUrl } from "../stream"; diff --git a/packages/ai/src/providers/openai-responses-server-schema.ts b/packages/ai/src/providers/openai-responses-server-schema.ts index 091720689..f3d52b4c1 100644 --- a/packages/ai/src/providers/openai-responses-server-schema.ts +++ b/packages/ai/src/providers/openai-responses-server-schema.ts @@ -8,7 +8,7 @@ * is a worse outcome than dropping them on the floor. */ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { EasyInputMessage, ResponseCreateParams, diff --git a/packages/ai/src/providers/openai-responses-server.ts b/packages/ai/src/providers/openai-responses-server.ts index db60dc466..890b52569 100644 --- a/packages/ai/src/providers/openai-responses-server.ts +++ b/packages/ai/src/providers/openai-responses-server.ts @@ -9,8 +9,8 @@ * Inverse direction (source-of-truth for item shapes): ../../providers/openai-responses.ts */ +import { type } from "@oh-my-pi/omptype"; import { logger, structuredCloneJSON } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { resolvePromptCacheKey } from "../auth-gateway/http"; import type { AuthGatewayStreamControl, AuthGatewayParsedRequest as ParsedRequest } from "../auth-gateway/types"; import * as AIError from "../error"; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e850eb004..67992a498 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,6 +1,7 @@ export * from "@oh-my-pi/pi-catalog/effort"; export * from "@oh-my-pi/pi-catalog/types"; +import type { Type } from "@oh-my-pi/omptype"; import type { DeleteArgs, DeleteResult, @@ -35,7 +36,6 @@ import type { import type { Effort } from "@oh-my-pi/pi-catalog/effort"; import { isOpenAIModelId } from "@oh-my-pi/pi-catalog/identity/family"; import type { Api, FetchImpl, KnownApi, Model, Provider, ThinkingBudgets, Usage } from "@oh-my-pi/pi-catalog/types"; -import type { Type } from "arktype"; import type { ZodType, z } from "zod/v4"; import type { ApiKey } from "./auth-retry"; import type { BedrockOptions } from "./providers/amazon-bedrock"; diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index b7df7550e..3770de78e 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -4,7 +4,7 @@ * Provides a normalized schema to represent multiple limit windows, model tiers, * and shared quotas across providers. */ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { FetchImpl, Provider } from "./types"; export type UsageUnit = "percent" | "tokens" | "requests" | "usd" | "minutes" | "bytes" | "unknown"; diff --git a/packages/ai/src/utils/schema/wire.ts b/packages/ai/src/utils/schema/wire.ts index 196bd657e..27c81fbc1 100644 --- a/packages/ai/src/utils/schema/wire.ts +++ b/packages/ai/src/utils/schema/wire.ts @@ -11,7 +11,7 @@ * JSON Schema dialect. */ -import type { Type } from "arktype"; +import type { Type } from "@oh-my-pi/omptype"; // We import the Zod *value* (z) for runtime APIs. Marker checks rely on the // `_zod` symbol that every Zod v4 schema instance carries. import { type ZodType, z } from "zod/v4"; diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 2dfe5d505..b649cb5b9 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -22,8 +22,9 @@ * keeps the schema in charge of acceptance — we never invent values, only * massage shapes the LLM almost got right. */ + +import { type Type, type } from "@oh-my-pi/omptype"; import { structuredCloneJSON } from "@oh-my-pi/pi-utils"; -import { type Type, type } from "arktype"; import type { ZodType } from "zod/v4"; import type { $ZodIssue as ZodIssue } from "zod/v4/core"; import * as AIError from "../error"; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index cd468958e..a2edada5e 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import * as tls from "node:tls"; +import { type as arkType } from "@oh-my-pi/omptype"; import { Effort } from "@oh-my-pi/pi-ai"; import { applyClaudeToolPrefix, @@ -32,7 +33,6 @@ import type { } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { removeSyncWithRetries } from "@oh-my-pi/pi-utils"; -import { type as arkType } from "arktype"; import { withEnv } from "./helpers"; const ANTHROPIC_MODEL_SPEC: ModelSpec<"anthropic-messages"> = { diff --git a/packages/ai/test/apply-patch-freeform.test.ts b/packages/ai/test/apply-patch-freeform.test.ts index a1a4b6c35..d38959461 100644 --- a/packages/ai/test/apply-patch-freeform.test.ts +++ b/packages/ai/test/apply-patch-freeform.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { buildTransformedCodexRequestBody, convertOpenAICodexResponsesTools as convertCodexTools, @@ -18,7 +19,6 @@ import { } from "@oh-my-pi/pi-ai/providers/openai-shared"; import type { AssistantMessage, Model, ModelSpec, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { type } from "arktype"; const GRAMMAR = [ "// top-level comment", diff --git a/packages/ai/test/auth-broker-wire-schema-contract.test.ts b/packages/ai/test/auth-broker-wire-schema-contract.test.ts index 428b42b58..0e0244645 100644 --- a/packages/ai/test/auth-broker-wire-schema-contract.test.ts +++ b/packages/ai/test/auth-broker-wire-schema-contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { Type, type } from "@oh-my-pi/omptype"; import * as wireSchemas from "@oh-my-pi/pi-ai/auth-broker/wire-schemas"; -import { Type, type } from "arktype"; const REFRESHER = { enabled: false, diff --git a/packages/ai/test/eval-language-whitespace.test.ts b/packages/ai/test/eval-language-whitespace.test.ts index 4d0b47f2e..c7e901eb8 100644 --- a/packages/ai/test/eval-language-whitespace.test.ts +++ b/packages/ai/test/eval-language-whitespace.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import type { Tool } from "@oh-my-pi/pi-ai/types"; import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; -import { type } from "arktype"; describe("Eval-tool language whitespace normalization", () => { it("trims a trailing newline on the ArkType-emitted language enum", () => { diff --git a/packages/ai/test/issue-1207-repro.test.ts b/packages/ai/test/issue-1207-repro.test.ts index bf6a572af..d2f0c4541 100644 --- a/packages/ai/test/issue-1207-repro.test.ts +++ b/packages/ai/test/issue-1207-repro.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Effort } from "@oh-my-pi/pi-catalog/effort"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { type } from "arktype"; const echoTool: Tool = { name: "echo", diff --git a/packages/ai/test/issue-1227-repro.test.ts b/packages/ai/test/issue-1227-repro.test.ts index 4b6a46a26..bcd87e8aa 100644 --- a/packages/ai/test/issue-1227-repro.test.ts +++ b/packages/ai/test/issue-1227-repro.test.ts @@ -16,11 +16,11 @@ * requires when tool history is present. */ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { AssistantMessage, Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { type } from "arktype"; function abortedSignal(): AbortSignal { const controller = new AbortController(); diff --git a/packages/ai/test/issue-827-repro.test.ts b/packages/ai/test/issue-827-repro.test.ts index d6641e895..6b62fa145 100644 --- a/packages/ai/test/issue-827-repro.test.ts +++ b/packages/ai/test/issue-827-repro.test.ts @@ -8,11 +8,11 @@ * reasoning for that single turn rather than dropping `tool_choice` outright. */ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { type } from "arktype"; const echoTool: Tool = { name: "echo", diff --git a/packages/ai/test/issue-945-repro.test.ts b/packages/ai/test/issue-945-repro.test.ts index e94270e74..d38b0bcf0 100644 --- a/packages/ai/test/issue-945-repro.test.ts +++ b/packages/ai/test/issue-945-repro.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { Context, Model, Tool } from "@oh-my-pi/pi-ai/types"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { type } from "arktype"; const echoTool: Tool = { name: "echo", diff --git a/packages/ai/test/openai-computer-contract.test.ts b/packages/ai/test/openai-computer-contract.test.ts index b3d023c65..718bca09d 100644 --- a/packages/ai/test/openai-computer-contract.test.ts +++ b/packages/ai/test/openai-computer-contract.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { buildTransformedCodexRequestBody, convertCodexResponsesMessages, @@ -20,7 +21,6 @@ import { import type { AssistantMessage, Context, Model, ModelSpec, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { sanitizeOpenAIResponsesHistoryItemsForReplay } from "@oh-my-pi/pi-ai/utils"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { type } from "arktype"; function model( api: TApi, diff --git a/packages/ai/test/openai-responses-history-payload.test.ts b/packages/ai/test/openai-responses-history-payload.test.ts index 58ebd7a64..4d3a16a6b 100644 --- a/packages/ai/test/openai-responses-history-payload.test.ts +++ b/packages/ai/test/openai-responses-history-payload.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { convertCodexResponsesMessages, streamOpenAICodexResponses, @@ -10,7 +11,6 @@ import { createOpenAIResponsesHistoryPayload, truncateResponseItemId } from "@oh import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { type GeneratedProvider, getBundledModel } from "@oh-my-pi/pi-catalog/models"; import * as piUtils from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; const TEST_INSTALLATION_ID = "00000000-0000-4000-8000-000000000001"; diff --git a/packages/ai/test/openai-responses-tool-quarantine.test.ts b/packages/ai/test/openai-responses-tool-quarantine.test.ts index f3e449104..cb661b9b8 100644 --- a/packages/ai/test/openai-responses-tool-quarantine.test.ts +++ b/packages/ai/test/openai-responses-tool-quarantine.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { buildParams, convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { Context, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types"; import { findStrictToolSchemaViolation } from "@oh-my-pi/pi-ai/utils/schema"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { type } from "arktype"; function makeModel(): Model<"openai-responses"> { return buildModel({ diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts index 727b6822d..f7d9a8081 100644 --- a/packages/ai/test/openai-tool-strict-mode.test.ts +++ b/packages/ai/test/openai-tool-strict-mode.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; import type { @@ -12,7 +13,6 @@ import type { } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { type } from "arktype"; const testTool: Tool = { name: "echo", @@ -271,7 +271,7 @@ describe("OpenAI tool strict mode", () => { name: "dynamic_map", description: "Dynamic object map", parameters: type({ - values: "Record?", + "values?": { "[string]": "string" }, }), }, ], diff --git a/packages/ai/test/remote-auth-store.test.ts b/packages/ai/test/remote-auth-store.test.ts index af0289854..3cc7c2492 100644 --- a/packages/ai/test/remote-auth-store.test.ts +++ b/packages/ai/test/remote-auth-store.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai"; import { AuthBrokerClient, @@ -15,7 +16,6 @@ import { import { snapshotResponseSchema } from "@oh-my-pi/pi-ai/auth-broker/wire-schemas"; import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage"; -import { type } from "arktype"; import { removeWithRetries } from "../../utils/src/temp"; function requireLimit(report: UsageReport, id: string): UsageLimit { diff --git a/packages/ai/test/schema-arktype.test.ts b/packages/ai/test/schema-arktype.test.ts index 3e084276b..bca358a40 100644 --- a/packages/ai/test/schema-arktype.test.ts +++ b/packages/ai/test/schema-arktype.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import type { Tool } from "@oh-my-pi/pi-ai/types"; import { isArkSchema, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; -import { type } from "arktype"; import { z } from "zod/v4"; // --------------------------------------------------------------------------- diff --git a/packages/ai/test/schema-wire.test.ts b/packages/ai/test/schema-wire.test.ts index a3aab7177..ed10c066c 100644 --- a/packages/ai/test/schema-wire.test.ts +++ b/packages/ai/test/schema-wire.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { normalizeAnthropicToolSchema } from "@oh-my-pi/pi-ai/providers/anthropic"; import type { Tool } from "@oh-my-pi/pi-ai/types"; import { @@ -13,7 +14,6 @@ import { toolWireSchema, zodToWireSchema, } from "@oh-my-pi/pi-ai/utils/schema"; -import { type } from "arktype"; import { z } from "zod/v4"; describe("isZodSchema", () => { diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 50eb3a93a..63caa3c76 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -3,6 +3,7 @@ import { type ChildProcess, execSync, spawn } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Effort } from "@oh-my-pi/pi-ai"; import { __resetVertexTokenCache } from "@oh-my-pi/pi-ai/providers/google-auth"; import { complete, getEnvApiKey, stream } from "@oh-my-pi/pi-ai/stream"; @@ -10,7 +11,6 @@ import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResult import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { $which } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { removeWithRetries } from "../../utils/src/temp"; import { e2eApiKey, resolveApiKey } from "./oauth"; diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index ebe74b0b3..ac18ff7d2 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, Tool } from "@oh-my-pi/pi-ai/types"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { type } from "arktype"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) diff --git a/packages/ai/test/tool-inventory.test.ts b/packages/ai/test/tool-inventory.test.ts index 6b9c3fa56..467f92733 100644 --- a/packages/ai/test/tool-inventory.test.ts +++ b/packages/ai/test/tool-inventory.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { renderToolInventory } from "../src/dialect/inventory"; import type { InbandTool } from "../src/dialect/types"; diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index e3d187fb2..8846d4540 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { type } from "arktype"; import { e2eApiKey, resolveApiKey } from "./oauth"; // Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist diff --git a/packages/ai/test/usage-report-notes-schema.test.ts b/packages/ai/test/usage-report-notes-schema.test.ts index b31f42985..7a6dce3d9 100644 --- a/packages/ai/test/usage-report-notes-schema.test.ts +++ b/packages/ai/test/usage-report-notes-schema.test.ts @@ -10,9 +10,9 @@ */ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { usageReportSchema } from "@oh-my-pi/pi-ai"; import { usageResponseSchema } from "@oh-my-pi/pi-ai/auth-broker/wire-schemas"; -import { type } from "arktype"; const DISCLAIMER = "OMP-observed spend only; OpenCode usage outside OMP is not included."; diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 34f40848f..804fbf1ec 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Replaced arktype with `@oh-my-pi/omptype` for discovery payload schemas (same definition DSL, lazy JIT validation). + ## [17.2.6] - 2026-08-03 ### Added diff --git a/packages/catalog/package.json b/packages/catalog/package.json index 26de647de..de318508a 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -35,8 +35,8 @@ }, "dependencies": { "@bufbuild/protobuf": "catalog:", + "@oh-my-pi/omptype": "catalog:", "@oh-my-pi/pi-utils": "catalog:", - "arktype": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/catalog/src/discovery/antigravity.ts b/packages/catalog/src/discovery/antigravity.ts index 1fc05d0e4..259c678c5 100644 --- a/packages/catalog/src/discovery/antigravity.ts +++ b/packages/catalog/src/discovery/antigravity.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { ModelSpec } from "../types"; import { discoveryFetch, toPositiveNumber } from "../utils"; import { diff --git a/packages/catalog/src/discovery/codex.ts b/packages/catalog/src/discovery/codex.ts index f87dfc62f..b8ecc5e58 100644 --- a/packages/catalog/src/discovery/codex.ts +++ b/packages/catalog/src/discovery/codex.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { parseKnownModel, semverEqual } from "../identity/classify"; import type { FetchImpl, ModelSpec } from "../types"; import { discoveryFetch } from "../utils"; diff --git a/packages/catalog/src/discovery/cursor.ts b/packages/catalog/src/discovery/cursor.ts index fe39c6cf8..76473a90e 100644 --- a/packages/catalog/src/discovery/cursor.ts +++ b/packages/catalog/src/discovery/cursor.ts @@ -1,6 +1,6 @@ import * as http2 from "node:http2"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { isKimiK3ModelId } from "../identity"; import { bareModelId, parseGlmModel, semverGte } from "../identity/classify"; import { getBundledModels } from "../models"; diff --git a/packages/catalog/src/discovery/gemini.ts b/packages/catalog/src/discovery/gemini.ts index 01f5889e3..02d9a7990 100644 --- a/packages/catalog/src/discovery/gemini.ts +++ b/packages/catalog/src/discovery/gemini.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { getBundledModels } from "../models"; import { toModelSpec } from "../provider-models/bundled-references"; import type { FetchImpl, Model, ModelSpec } from "../types"; diff --git a/packages/catalog/src/discovery/openai-compatible.ts b/packages/catalog/src/discovery/openai-compatible.ts index 7d015a304..a673077a0 100644 --- a/packages/catalog/src/discovery/openai-compatible.ts +++ b/packages/catalog/src/discovery/openai-compatible.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { Api, FetchImpl, ModelSpec, Provider } from "../types"; import { discoveryFetch } from "../utils"; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9c270a524..40b91d9dd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,8 +2,13 @@ ## [Unreleased] +### Changed + +- Replaced arktype with `@oh-my-pi/omptype` across all tool parameter and config schemas: ~100x faster schema construction removes the arktype startup tax (the `scope({}, { jitless: true })` workarounds are gone). Config schema errors now report via `OmpErrors` entries with the same `path`/`problem` shape. + ### Fixed +- Fixed extension/custom/hook tool wrappers stripping schema methods off `parameters`: `applyToolProxy` bound every callable property, and binding a schema (a plain function carrying `toJsonSchema`/`assert`) dropped those properties, breaking wire-schema detection and crashing the status-line token estimator with `JSON.stringify(schema) === undefined`. Prototype methods are still bound; own data properties and schema callables now pass through untouched. - Fixed bug where `agent()` calls in eval cells ignored turn cancellation and continued running indefinitely - Fixed the built-in `tail` printing `tail: Broken pipe` and failing when a downstream pipeline reader exited early (e.g. `tail -c N file.jsonl | jq …` with jq aborting on a parse error); it now exits silently with 141 (128+SIGPIPE) like a real tail, in every output path including `--follow`. - Fixed the in-process ps shell builtin rejecting common procps/BSD format specifiers (`ps -o tpgid,...` failed with `unknown output format specifier`); added `tpgid`, `pri`, `flags`, real/effective user and group columns, `wchan`, fault counters, `sz`, and the STAT `+` foreground flag. @@ -2264,7 +2269,7 @@ ### Fixed -- Fixed `omp install` of legacy pi extensions failing with `Cannot find module '/$bunfs/root/packages/coding-agent/src/extensibility/typebox.js'` on every released `omp--` binary. Commit `dc5c93462f` removed worker entrypoints from `scripts/ci-release-build-binaries.ts`; the inline comment then claimed the legacy-shim and package-barrel entrypoints (`typebox.ts`, `legacy-pi-{ai,coding-agent}-shim.ts`, `packages/{agent,natives,tui,utils}/...`) were "still" passed to `bun build --compile`, but they had never been re-added. The release binaries shipped without those files in bunfs, so `legacy-pi-compat.ts` redirected `typebox` imports to a bunfs path that didn't exist. `__resolveTypeBoxShimPath` now mirrors `__validateLegacyPiPackageRootOverrides` (#2168) by dropping the override when the shim file is missing, so a missing shim falls through to native `node_modules` resolution instead of emitting a dead bunfs URL ([#3414](https://github.com/can1357/oh-my-pi/issues/3414)). +- Fixed `omp install` of legacy pi extensions failing because the compiled binary omitted the coding-agent TypeBox shim on every released `omp--` binary. Commit `dc5c93462f` removed worker entrypoints from `scripts/ci-release-build-binaries.ts`; the inline comment then claimed the legacy-shim and package-barrel entrypoints (`typebox.ts`, `legacy-pi-{ai,coding-agent}-shim.ts`, `packages/{agent,natives,tui,utils}/...`) were "still" passed to `bun build --compile`, but they had never been re-added. The release binaries shipped without those files in bunfs, so `legacy-pi-compat.ts` redirected `typebox` imports to a bunfs path that didn't exist. `__resolveTypeBoxShimPath` now mirrors `__validateLegacyPiPackageRootOverrides` (#2168) by dropping the override when the shim file is missing, so a missing shim falls through to native `node_modules` resolution instead of emitting a dead bunfs URL ([#3414](https://github.com/can1357/oh-my-pi/issues/3414)). - Fixed every legacy `@(scope)/pi-*` and `@sinclair/typebox` import failing to load on the `omp-darwin-arm64` release binary (and any other `omp` built with Bun 1.3.14). `__validateLegacyPiPackageRootOverrides` and the `rewriteLegacyPiImports` emit path both depended on `--compile` extras being reachable as `/$bunfs/root/...` filesystem entries, but Bun 1.3.14 stopped exposing them through every API (`fs.existsSync`, `Bun.file().exists()`, `Bun.resolveSync`, `await import()` on the bunfs path or its `file://` URL all fail; only `/$bunfs/root/` itself answers). `legacy-pi-compat.ts` now keeps a JS-heap reference to every bundled pi-* surface in a lazy-loaded sibling `legacy-pi-bundled-registry.ts` and serves them through an `omp-legacy-pi-bundled:` virtual namespace whose `Bun.plugin().onLoad` returns synthetic re-exports — no bunfs path ever leaves the module in compiled mode, and dev / source-link / installed-package modes keep the historical `file://` rewrite. The matching `--compile` extras in `scripts/build-binary.ts`, the shared `scripts/binary-entrypoints.ts` list, and the dead `BUNFS_PACKAGE_ROOT` / `bunfsPath` / `__computeBunfsPackageRoot` / `__joinBunfsPath` helpers are gone. ([#3423](https://github.com/can1357/oh-my-pi/issues/3423)) ## [16.1.17] - 2026-06-24 diff --git a/packages/coding-agent/bench/rendering.ts b/packages/coding-agent/bench/rendering.ts index 649dcc5ea..abb82b312 100644 --- a/packages/coding-agent/bench/rendering.ts +++ b/packages/coding-agent/bench/rendering.ts @@ -1,18 +1,22 @@ -import { initTheme } from "../src/modes/theme/theme"; -import { truncateToVisualLines } from "../src/modes/components/visual-truncate"; -import { WelcomeComponent } from "../src/modes/components/welcome"; -import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { Editor } from "@oh-my-pi/pi-tui"; -import { AssistantMessageComponent } from "../src/modes/components/assistant-message"; -import { TranscriptContainer } from "../src/modes/components/transcript-container"; -import { Settings } from "../src/config/settings"; -import { getEditorTheme } from "../src/modes/theme/theme"; -import { BlockUnitCounter, buildDisplayMessage, nextStep, visibleUnits } from "../src/modes/controllers/streaming-reveal"; +import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import * as fs from "node:fs"; -import { ReadTool } from "../src/tools/read"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { Editor } from "@oh-my-pi/pi-tui"; +import { Settings } from "../src/config/settings"; +import { AssistantMessageComponent } from "../src/modes/components/assistant-message"; +import { TranscriptContainer } from "../src/modes/components/transcript-container"; +import { truncateToVisualLines } from "../src/modes/components/visual-truncate"; +import { WelcomeComponent } from "../src/modes/components/welcome"; +import { + BlockUnitCounter, + buildDisplayMessage, + nextStep, + visibleUnits, +} from "../src/modes/controllers/streaming-reveal"; +import { getEditorTheme, initTheme } from "../src/modes/theme/theme"; import type { ToolSession } from "../src/tools"; +import { ReadTool } from "../src/tools/read"; const ITERATIONS = 500; const WIDTH = 100; @@ -41,13 +45,19 @@ bench("truncateToVisualLines", () => { truncateToVisualLines(longText, 20, WIDTH, 1); }); -const welcome = new WelcomeComponent("8.12.3", "claude-3.7", "anthropic", [ - { name: "Test session", timeAgo: "2m" }, - { name: "Another session", timeAgo: "1h" }, -], [ - { name: "tsserver", status: "ready", fileTypes: ["ts", "tsx", "js"] }, - { name: "rust-analyzer", status: "connecting", fileTypes: ["rs"] }, -]); +const welcome = new WelcomeComponent( + "8.12.3", + "claude-3.7", + "anthropic", + [ + { name: "Test session", timeAgo: "2m" }, + { name: "Another session", timeAgo: "1h" }, + ], + [ + { name: "tsserver", status: "ready", fileTypes: ["ts", "tsx", "js"] }, + { name: "rust-analyzer", status: "connecting", fileTypes: ["rs"] }, + ], +); bench("WelcomeComponent.render", () => { welcome.render(WIDTH); @@ -83,7 +93,14 @@ function makeTextMessage(text: string): AssistantMessage { api: "anthropic-messages", provider: "anthropic", model: "bench", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, stopReason: "stop", timestamp: 0, }; @@ -152,7 +169,13 @@ try { // text block — the shape C2 targets. Current code re-lexes BOTH every tick; // after C2 the finalized thinking block stays L1-cached and only the tail re-lexes. function makeThinkingPlusText(thinking: string, text: string): AssistantMessage { - return { ...makeTextMessage(text), content: [{ type: "thinking", thinking }, { type: "text", text }] }; + return { + ...makeTextMessage(text), + content: [ + { type: "thinking", thinking }, + { type: "text", text }, + ], + }; } console.log("\nstreamingRevealMultiBlock (C2: finalized thinking block + growing text):"); try { @@ -174,7 +197,9 @@ try { steps++; } const ms = (Bun.nanoseconds() - start) / 1e6; - console.log(` text=${n} (+2500 thinking): ${ms.toFixed(2)}ms total over ${steps} steps (${(ms / steps).toFixed(4)}ms/step)`); + console.log( + ` text=${n} (+2500 thinking): ${ms.toFixed(2)}ms total over ${steps} steps (${(ms / steps).toFixed(4)}ms/step)`, + ); } } catch (err) { console.log(` (skipped: ${(err as Error).message})`); diff --git a/packages/coding-agent/bench/streaming-throughput.bench.ts b/packages/coding-agent/bench/streaming-throughput.bench.ts index ca51fdcd9..f6229cfc8 100644 --- a/packages/coding-agent/bench/streaming-throughput.bench.ts +++ b/packages/coding-agent/bench/streaming-throughput.bench.ts @@ -121,5 +121,9 @@ const msPerStep = elapsedMs / totalTicks; console.log(`METRIC reveal_ms_per_episode=${msPerEpisode.toFixed(4)}`); console.log(`METRIC reveal_ms_per_step=${msPerStep.toFixed(5)}`); -console.log(`ASI graphemes=${graphemes} episodes=${MEASURE_EPISODES} ticks_per_episode=${(totalTicks / MEASURE_EPISODES).toFixed(2)} warmup=${WARMUP_EPISODES}`); -console.log(`(reveal: ${graphemes} graphemes, ${(totalTicks / MEASURE_EPISODES).toFixed(1)} ticks/episode, ${msPerEpisode.toFixed(3)} ms/episode, ${msPerStep.toFixed(4)} ms/step)`); +console.log( + `ASI graphemes=${graphemes} episodes=${MEASURE_EPISODES} ticks_per_episode=${(totalTicks / MEASURE_EPISODES).toFixed(2)} warmup=${WARMUP_EPISODES}`, +); +console.log( + `(reveal: ${graphemes} graphemes, ${(totalTicks / MEASURE_EPISODES).toFixed(1)} ticks/episode, ${msPerEpisode.toFixed(3)} ms/episode, ${msPerStep.toFixed(4)} ms/step)`, +); diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index e1d68afbd..23448f4b7 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -54,6 +54,7 @@ "@mozilla/readability": "catalog:", "@oh-my-pi/hashline": "catalog:", "@oh-my-pi/omp-stats": "catalog:", + "@oh-my-pi/omptype": "catalog:", "@oh-my-pi/pi-agent-core": "catalog:", "@oh-my-pi/pi-ai": "catalog:", "@oh-my-pi/pi-catalog": "catalog:", @@ -77,7 +78,6 @@ "@puppeteer/browsers": "catalog:", "@types/turndown": "catalog:", "@xterm/headless": "catalog:", - "arktype": "catalog:", "chalk": "catalog:", "fast-xml-parser": "catalog:", "handlebars": "catalog:", diff --git a/packages/coding-agent/scripts/legacy-pi-virtual-module.ts b/packages/coding-agent/scripts/legacy-pi-virtual-module.ts index 97426ca4a..41abffabe 100644 --- a/packages/coding-agent/scripts/legacy-pi-virtual-module.ts +++ b/packages/coding-agent/scripts/legacy-pi-virtual-module.ts @@ -24,7 +24,7 @@ const BUNDLED_PACKAGES: readonly BundledPackage[] = [ ]; const TYPEBOX_MODULE_KEY = "typebox"; -const TYPEBOX_SHIM = "typebox.ts"; +const TYPEBOX_COMPAT_MODULE = "legacy-typebox.ts"; const SKIPPED_WILDCARD_BASENAMES = new Set(["index"]); const MAIN_THREAD_UNSAFE_WILDCARD_BASENAMES = new Set(["worker-entry"]); @@ -172,7 +172,7 @@ export async function collectBundledPiEntries(): Promise { } } - addEntry(TYPEBOX_MODULE_KEY, "bundledTypeBoxShim", shimSpecifier(TYPEBOX_SHIM)); + addEntry(TYPEBOX_MODULE_KEY, "bundledTypeBoxShim", shimSpecifier(TYPEBOX_COMPAT_MODULE)); return entries; } diff --git a/packages/coding-agent/src/advisor/advise-tool.ts b/packages/coding-agent/src/advisor/advise-tool.ts index 91e5e328d..d9cb954e2 100644 --- a/packages/coding-agent/src/advisor/advise-tool.ts +++ b/packages/coding-agent/src/advisor/advise-tool.ts @@ -1,3 +1,4 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentIdentity, AgentTelemetryConfig, @@ -7,7 +8,6 @@ import type { AgentToolUpdateCallback, } from "@oh-my-pi/pi-agent-core"; import { escapeXmlAttribute, escapeXmlText } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" }; const adviseSchema = type({ diff --git a/packages/coding-agent/src/advisor/config.ts b/packages/coding-agent/src/advisor/config.ts index 01c92c328..9429ca54b 100644 --- a/packages/coding-agent/src/advisor/config.ts +++ b/packages/coding-agent/src/advisor/config.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { YAML } from "bun"; import { expandAtImports } from "../discovery/at-imports"; import { BUILTIN_TOOL_NAMES, normalizeToolNames } from "../tools/builtin-names"; diff --git a/packages/coding-agent/src/autoresearch/tools/init-experiment.ts b/packages/coding-agent/src/autoresearch/tools/init-experiment.ts index 320d98e66..9cf787bf5 100644 --- a/packages/coding-agent/src/autoresearch/tools/init-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/init-experiment.ts @@ -1,7 +1,6 @@ import * as path from "node:path"; - +import { type } from "@oh-my-pi/omptype"; import { Text } from "@oh-my-pi/pi-tui"; -import { type } from "arktype"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; diff --git a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts index 7ceffd8c7..2702dc6f2 100644 --- a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts @@ -1,8 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; - +import { type } from "@oh-my-pi/omptype"; import { Text } from "@oh-my-pi/pi-tui"; -import { type } from "arktype"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; diff --git a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts index 0919e4643..08672946e 100644 --- a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts +++ b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts @@ -1,8 +1,8 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Text } from "@oh-my-pi/pi-tui"; import { formatBytes } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { executeBash } from "../../exec/bash-executor"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; diff --git a/packages/coding-agent/src/autoresearch/tools/update-notes.ts b/packages/coding-agent/src/autoresearch/tools/update-notes.ts index bfec01af4..5ddcdeb22 100644 --- a/packages/coding-agent/src/autoresearch/tools/update-notes.ts +++ b/packages/coding-agent/src/autoresearch/tools/update-notes.ts @@ -1,5 +1,5 @@ +import { type } from "@oh-my-pi/omptype"; import { Text } from "@oh-my-pi/pi-tui"; -import { type } from "arktype"; import type { ToolDefinition } from "../../extensibility/extensions"; import type { Theme } from "../../modes/theme/theme"; import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; diff --git a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts index b9b90c8b2..04b88636b 100644 --- a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts +++ b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts @@ -1,5 +1,5 @@ +import { type } from "@oh-my-pi/omptype"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import analyzeFilePrompt from "../../../commit/agentic/prompts/analyze-file.md" with { type: "text" }; import type { CommitAgentState } from "../../../commit/agentic/state"; import type { NumstatEntry } from "../../../commit/types"; diff --git a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts index 9333da3c9..cd846c43c 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-file-diff.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { CommitAgentState } from "../../../commit/agentic/state"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; diff --git a/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts b/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts index cd7f5c768..0b72c65b0 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-hunk.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { DiffHunk, FileHunks } from "../../../commit/types"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; diff --git a/packages/coding-agent/src/commit/agentic/tools/git-overview.ts b/packages/coding-agent/src/commit/agentic/tools/git-overview.ts index 8daed8da5..f8dc26ddb 100644 --- a/packages/coding-agent/src/commit/agentic/tools/git-overview.ts +++ b/packages/coding-agent/src/commit/agentic/tools/git-overview.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { CommitAgentState, GitOverviewSnapshot } from "../../../commit/agentic/state"; import { extractScopeCandidates } from "../../../commit/analysis/scope"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; diff --git a/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts b/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts index b5127b81c..750fa0b79 100644 --- a/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts +++ b/packages/coding-agent/src/commit/agentic/tools/propose-changelog.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { CommitAgentState } from "../../../commit/agentic/state"; import { CHANGELOG_CATEGORIES, type ChangelogCategory } from "../../../commit/types"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; diff --git a/packages/coding-agent/src/commit/agentic/tools/propose-commit.ts b/packages/coding-agent/src/commit/agentic/tools/propose-commit.ts index 3af4ed559..644cf699e 100644 --- a/packages/coding-agent/src/commit/agentic/tools/propose-commit.ts +++ b/packages/coding-agent/src/commit/agentic/tools/propose-commit.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { CommitAgentState } from "../../../commit/agentic/state"; import { capDetails, diff --git a/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts b/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts index 534fd8245..be7894cfa 100644 --- a/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts +++ b/packages/coding-agent/src/commit/agentic/tools/recent-commits.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { CustomTool } from "../../../extensibility/custom-tools/types"; import * as git from "../../../utils/git"; diff --git a/packages/coding-agent/src/commit/agentic/tools/schemas.ts b/packages/coding-agent/src/commit/agentic/tools/schemas.ts index d39572934..0335eae9f 100644 --- a/packages/coding-agent/src/commit/agentic/tools/schemas.ts +++ b/packages/coding-agent/src/commit/agentic/tools/schemas.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; export const commitTypeSchema = type( "'feat' | 'fix' | 'refactor' | 'perf' | 'docs' | 'test' | 'build' | 'ci' | 'chore' | 'style' | 'revert'", diff --git a/packages/coding-agent/src/commit/agentic/tools/split-commit.ts b/packages/coding-agent/src/commit/agentic/tools/split-commit.ts index 0c0622035..98e7bb29d 100644 --- a/packages/coding-agent/src/commit/agentic/tools/split-commit.ts +++ b/packages/coding-agent/src/commit/agentic/tools/split-commit.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { CommitAgentState, SplitCommitGroup, SplitCommitPlan } from "../../../commit/agentic/state"; import { computeDependencyOrder } from "../../../commit/agentic/topo-sort"; import { diff --git a/packages/coding-agent/src/commit/analysis/summary.ts b/packages/coding-agent/src/commit/analysis/summary.ts index 8e371e183..fba232a40 100644 --- a/packages/coding-agent/src/commit/analysis/summary.ts +++ b/packages/coding-agent/src/commit/analysis/summary.ts @@ -1,8 +1,8 @@ +import { type } from "@oh-my-pi/omptype"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import summarySystemPrompt from "../../commit/prompts/summary-system.md" with { type: "text" }; import summaryUserPrompt from "../../commit/prompts/summary-user.md" with { type: "text" }; import type { CommitSummary } from "../../commit/types"; diff --git a/packages/coding-agent/src/commit/changelog/generate.ts b/packages/coding-agent/src/commit/changelog/generate.ts index bab948926..921881e33 100644 --- a/packages/coding-agent/src/commit/changelog/generate.ts +++ b/packages/coding-agent/src/commit/changelog/generate.ts @@ -1,8 +1,8 @@ +import { type } from "@oh-my-pi/omptype"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Api, ApiKey, AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import changelogSystemPrompt from "../../commit/prompts/changelog-system.md" with { type: "text" }; import changelogUserPrompt from "../../commit/prompts/changelog-user.md" with { type: "text" }; import type { ChangelogGenerationResult } from "../../commit/types"; diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index d098250e6..d36916870 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { OmpErrors, type Type } from "@oh-my-pi/omptype"; import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { ArkErrors, type Type } from "arktype"; import { JSONC, YAML } from "bun"; /** Minimal subset of the AJV ConfigSchemaError shape this module actually relies on. */ @@ -252,7 +252,7 @@ export class ConfigFile implements IConfigFile { } const checked = this.schema(parsed); - if (checked instanceof ArkErrors) { + if (checked instanceof OmpErrors) { const schemaErrors: ConfigSchemaError[] = checked.map(error => ({ instancePath: error.path.length === 0 ? "root" : error.path.join("."), message: error.problem, diff --git a/packages/coding-agent/src/config/models-config-schema-bundle.ts b/packages/coding-agent/src/config/models-config-schema-bundle.ts index 69055b423..2620360ff 100644 --- a/packages/coding-agent/src/config/models-config-schema-bundle.ts +++ b/packages/coding-agent/src/config/models-config-schema-bundle.ts @@ -1,14 +1,7 @@ +import { type } from "@oh-my-pi/omptype"; import { once } from "@oh-my-pi/pi-utils"; -import { scope } from "arktype"; export const getModelsConfigSchemaBundle = once(() => { - // Config schemas validate at most a handful of times per process (on config - // load), so the eager JIT codegen ArkType runs at definition time is pure - // startup tax. A local jitless scope skips that codegen and falls back to - // interpreted traversal — ~65% cheaper to construct, validation correctness - // unchanged. (No `name`: duplicate module instances would collide.) - const { type } = scope({}, { jitless: true }); - const OpenRouterRoutingSchema = type({ "only?": "string[]", "order?": "string[]", diff --git a/packages/coding-agent/src/edit/hashline/params.ts b/packages/coding-agent/src/edit/hashline/params.ts index 2c9b2baee..4265f6bb3 100644 --- a/packages/coding-agent/src/edit/hashline/params.ts +++ b/packages/coding-agent/src/edit/hashline/params.ts @@ -3,7 +3,7 @@ * deliberately permissive (allows extra keys) so providers can attach extra * keys without rejection; only `input` is required. */ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; export const hashlineEditParamsSchema = type({ input: "string", diff --git a/packages/coding-agent/src/edit/modes/apply-patch.ts b/packages/coding-agent/src/edit/modes/apply-patch.ts index 94e0510c3..64d736396 100644 --- a/packages/coding-agent/src/edit/modes/apply-patch.ts +++ b/packages/coding-agent/src/edit/modes/apply-patch.ts @@ -8,7 +8,7 @@ * the `patch` mode. */ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { parseApplyPatch, parseApplyPatchStreaming } from "../apply-patch/parser"; import { ApplyPatchError } from "../diff"; import type { PatchEditEntry } from "./patch"; diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index 68d078421..feadc35fa 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -7,9 +7,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { isEnoent } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { type FileDiagnosticsResult, flushLspWritethroughBatch, diff --git a/packages/coding-agent/src/edit/modes/replace.ts b/packages/coding-agent/src/edit/modes/replace.ts index 5fdb28859..e387ae38c 100644 --- a/packages/coding-agent/src/edit/modes/replace.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -4,8 +4,9 @@ * Provides both character-level and line-level fuzzy matching with progressive * fallback strategies for finding text in files. */ + +import { type } from "@oh-my-pi/omptype"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { type } from "arktype"; import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; import type { ToolSession } from "../../tools"; import { routeWriteThroughBridge } from "../../tools/acp-bridge"; diff --git a/packages/coding-agent/src/eval/agent-bridge.ts b/packages/coding-agent/src/eval/agent-bridge.ts index 06080d5fe..07483da39 100644 --- a/packages/coding-agent/src/eval/agent-bridge.ts +++ b/packages/coding-agent/src/eval/agent-bridge.ts @@ -1,7 +1,7 @@ /** * Host-side handler for the eval `agent()` helper. */ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { buildStructuredSubagentRecoveryHint, runStructuredSubagent, diff --git a/packages/coding-agent/src/eval/completion-bridge.ts b/packages/coding-agent/src/eval/completion-bridge.ts index 03bf6d778..4a827c0ce 100644 --- a/packages/coding-agent/src/eval/completion-bridge.ts +++ b/packages/coding-agent/src/eval/completion-bridge.ts @@ -11,10 +11,11 @@ * The call is oneshot and toolless from the model's perspective — pure text * in, text (or, with `schema`, a structured object) out. */ + +import { type } from "@oh-my-pi/omptype"; import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; import { type Api, Effort, type Model, type Tool } from "@oh-my-pi/pi-ai"; import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; -import { type } from "arktype"; import { extractTextContent, extractToolCall, parseJsonPayload } from "../commit/utils"; import { @@ -46,7 +47,7 @@ const completionArgsSchema = type({ prompt: "string>0", "model?": "'smol'|'default'|'slow'", "system?": "string", - "schema?": "Record", + "schema?": { "[string]": "unknown" }, }); export interface EvalCompletionBridgeOptions { diff --git a/packages/coding-agent/src/extensibility/custom-commands/loader.ts b/packages/coding-agent/src/extensibility/custom-commands/loader.ts index cf410a0b6..ea6485206 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/loader.ts @@ -6,14 +6,14 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; +import * as arktype from "@oh-my-pi/omptype"; +import * as zodModule from "@oh-my-pi/omptype/zod"; import { getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import * as arktype from "arktype"; -import * as zodModule from "zod/v4"; import { getConfigDirs } from "../../config"; import { execCommand } from "../../exec/exec"; // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. import * as PiCodingAgent from "../../index"; -import * as typebox from "../typebox"; +import * as typebox from "../legacy-typebox"; import { GreenCommand } from "./bundled/ci-green"; import { ReviewCommand } from "./bundled/review"; import type { diff --git a/packages/coding-agent/src/extensibility/custom-commands/types.ts b/packages/coding-agent/src/extensibility/custom-commands/types.ts index 4bde6c2f3..c1adda2ad 100644 --- a/packages/coding-agent/src/extensibility/custom-commands/types.ts +++ b/packages/coding-agent/src/extensibility/custom-commands/types.ts @@ -5,11 +5,11 @@ * Unlike markdown commands which expand to prompts, custom commands can execute * arbitrary logic with full access to the hook context. */ -import type * as arktype from "arktype"; -import type * as zod from "zod/v4"; +import type * as arktype from "@oh-my-pi/omptype"; +import type * as TypeBox from "@oh-my-pi/omptype/typebox"; +import type * as zod from "@oh-my-pi/omptype/zod"; import type { ExecOptions, ExecResult, HookCommandContext } from "../../extensibility/hooks/types"; import type * as PiCodingAgent from "../../index"; -import type * as TypeBox from "../typebox"; // Re-export for custom commands to use export type { ExecOptions, ExecResult, HookCommandContext }; @@ -27,7 +27,7 @@ export interface CustomCommandAPI { typebox: typeof TypeBox; /** Injected arktype module for validation in custom commands. */ arktype: typeof arktype; - /** Injected zod/v4 module for canonical command validation. */ + /** Injected omptype-backed zod facade for command validation. */ zod: typeof zod; /** Injected pi-coding-agent exports */ pi: typeof PiCodingAgent; diff --git a/packages/coding-agent/src/extensibility/custom-tools/loader.ts b/packages/coding-agent/src/extensibility/custom-tools/loader.ts index f3bdfd3b2..89c8e9f4c 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/loader.ts @@ -5,10 +5,10 @@ * CustomToolAPI to avoid import resolution issues with custom tools loaded from user directories. */ import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; +import * as zodModule from "@oh-my-pi/omptype/zod"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { logger } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; -import * as zodModule from "zod/v4"; import { toolCapability } from "../../capability/tool"; import { type CustomTool, loadCapability } from "../../discovery"; import type { ExecOptions } from "../../exec/exec"; @@ -17,7 +17,7 @@ import type { HookUIContext } from "../../extensibility/hooks/types"; import { getAllPluginToolPaths } from "../../extensibility/plugins/loader"; // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. import * as PiCodingAgent from "../../index"; -import * as typebox from "../typebox"; +import * as typebox from "../legacy-typebox"; import { createNoOpUIContext, resolvePath, withHostGuard } from "../utils"; import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types"; diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts index 3882a5924..79da0e43c 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -4,6 +4,10 @@ * Custom tools are TypeScript modules that define additional tools for the agent. * They can provide custom rendering for tool calls and results in the TUI. */ + +import type { type as ArkType } from "@oh-my-pi/omptype"; +import type * as TypeBox from "@oh-my-pi/omptype/typebox"; +import type * as zod from "@oh-my-pi/omptype/zod"; import type { AgentToolResult, AgentToolUpdateCallback, @@ -16,8 +20,6 @@ import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { FetchImpl, Model, Static, TSchema } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; -import type { type as ArkType } from "arktype"; -import type * as zod from "zod/v4"; import type { Rule } from "../../capability/rule"; import type { ModelRegistry } from "../../config/model-registry"; import type { Settings } from "../../config/settings"; @@ -29,7 +31,6 @@ import type { Theme } from "../../modes/theme/theme"; import type { ReadonlySessionManager } from "../../session/session-manager"; import type { TodoItem } from "../../tools/todo"; import type { RecoveredRetryError } from "../shared-events"; -import type * as TypeBox from "../typebox"; /** Alias for clarity */ export type CustomToolUIContext = HookUIContext; @@ -69,7 +70,7 @@ export interface CustomToolAPI { typebox: typeof TypeBox; /** Injected arktype module for arktype-authored custom tools. */ arktype: typeof ArkType; - /** Injected zod/v4 module for canonical parameter schemas. */ + /** Injected omptype-backed zod facade for parameter schemas. */ zod: typeof zod; /** Injected pi-coding-agent exports */ pi: typeof PiCodingAgent; diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index 000ad6581..9e8abae74 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -4,6 +4,8 @@ import type * as fs1 from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { Type } from "@oh-my-pi/omptype"; +import * as zodModule from "@oh-my-pi/omptype/zod"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, @@ -16,8 +18,6 @@ import type { } from "@oh-my-pi/pi-ai"; import type { KeyId } from "@oh-my-pi/pi-tui"; import { hasFsCode, isEacces, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import { Type } from "arktype"; -import * as zodModule from "zod/v4"; import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module"; import { type Hook, hookCapability } from "../../capability/hook"; import { isServiceTierFamily, isServiceTierForFamily } from "../../config/service-tier"; @@ -29,9 +29,9 @@ import { execCommand } from "../../exec/exec"; import * as PiCodingAgent from "../../index"; import type { CustomMessagePayload } from "../../session/messages"; import { EventBus } from "../../utils/event-bus"; +import * as TypeBox from "../legacy-typebox"; import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat"; import { getAllPluginExtensionPaths } from "../plugins/loader"; -import * as TypeBox from "../typebox"; import { resolvePath, withHostGuard } from "../utils"; import type { diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 232291225..49067f17b 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -7,6 +7,10 @@ * - Register commands, keyboard shortcuts, and CLI flags * - Interact with the user via UI primitives */ + +import type { Type as arktype } from "@oh-my-pi/omptype"; +import type * as TypeBox from "@oh-my-pi/omptype/typebox"; +import type * as zod from "@oh-my-pi/omptype/zod"; import type { AgentMessage, AgentToolResult, @@ -36,8 +40,6 @@ import type { import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; import type { AutocompleteItem, AutocompleteProvider, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; -import type { Type as arktype } from "arktype"; -import type * as zod from "zod/v4"; import type { KeybindingsManager } from "../../config/keybindings"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; @@ -102,7 +104,6 @@ import type { TurnStartEvent, } from "../shared-events"; import type { SlashCommandInfo } from "../slash-commands"; -import type * as TypeBox from "../typebox"; export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; export type { ExecOptions, ExecResult } from "../../exec/exec"; @@ -1113,7 +1114,7 @@ export interface ExtensionAPI { /** Injected arktype module for arktype-authored extension tools (canonical going forward). */ arktype: typeof arktype; - /** Injected zod/v4 module for canonical extension tool parameter schemas. */ + /** Injected omptype-backed zod facade for extension tool parameter schemas. */ zod: typeof zod; /** Injected pi-coding-agent exports for accessing SDK utilities */ diff --git a/packages/coding-agent/src/extensibility/hooks/loader.ts b/packages/coding-agent/src/extensibility/hooks/loader.ts index 17e23998c..6ed70aaab 100644 --- a/packages/coding-agent/src/extensibility/hooks/loader.ts +++ b/packages/coding-agent/src/extensibility/hooks/loader.ts @@ -2,16 +2,16 @@ * Hook loader - loads TypeScript hook modules using native Bun import. */ import * as path from "node:path"; +import * as arktype from "@oh-my-pi/omptype"; +import * as zodModule from "@oh-my-pi/omptype/zod"; import { logger } from "@oh-my-pi/pi-utils"; -import * as arktype from "arktype"; -import * as zodModule from "zod/v4"; import { hookCapability } from "../../capability/hook"; import type { Hook } from "../../discovery"; import { loadCapability } from "../../discovery"; // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe. import * as PiCodingAgent from "../../index"; import type { CustomMessagePayload } from "../../session/messages"; -import * as typebox from "../typebox"; +import * as typebox from "../legacy-typebox"; import { resolvePath, withHostGuard } from "../utils"; import { execCommand } from "./runner"; import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types"; diff --git a/packages/coding-agent/src/extensibility/hooks/types.ts b/packages/coding-agent/src/extensibility/hooks/types.ts index 6fe44f305..e0531edd4 100644 --- a/packages/coding-agent/src/extensibility/hooks/types.ts +++ b/packages/coding-agent/src/extensibility/hooks/types.ts @@ -1,8 +1,9 @@ +import type { Type } from "@oh-my-pi/omptype"; +import type * as TypeBox from "@oh-my-pi/omptype/typebox"; +import type * as zod from "@oh-my-pi/omptype/zod"; import type { ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai"; import type { Component, TUI } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; -import type { Type } from "arktype"; -import type * as zod from "zod/v4"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "../../edit"; import type { ExecOptions, ExecResult } from "../../exec/exec"; @@ -43,7 +44,6 @@ import type { TurnEndEvent, TurnStartEvent, } from "../shared-events"; -import type * as TypeBox from "../typebox"; // Re-export for backward compatibility export type { ExecOptions, ExecResult } from "../../exec/exec"; @@ -586,7 +586,7 @@ export interface HookAPI { typebox: typeof TypeBox; /** Injected arktype module for arktype-authored hooks. */ arktype: typeof Type; - /** Injected zod/v4 module for canonical hook validation. */ + /** Injected omptype-backed zod facade for hook validation. */ zod: typeof zod; /** Injected pi-coding-agent exports */ pi: typeof PiCodingAgent; diff --git a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts index 26e832d92..49603e015 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts @@ -37,7 +37,7 @@ import { getBundledProviders, modelsAreEqual, } from "@oh-my-pi/pi-catalog/models"; -import { type TSchema, Type } from "./typebox"; +import { type TSchema, Type } from "./legacy-typebox"; export interface StringEnumOptions { description?: string; diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts index 0d131f292..064d79dc4 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts @@ -57,10 +57,10 @@ import { EventBus } from "../utils/event-bus"; import { discoverExtensionPaths, loadExtensionFromFactory, loadExtensions } from "./extensions"; import { ExtensionRuntime } from "./extensions/loader"; import type { ExtensionFactory, ToolDefinition } from "./extensions/types"; +import { Type } from "./legacy-typebox"; import { getEnabledPlugins, resolvePluginExtensionPaths, type ScopedInstalledPlugin } from "./plugins/loader"; import type { Skill } from "./skills"; import { loadSkillsFromDir } from "./skills"; -import { Type } from "./typebox"; const TOOL_DEFINITION_MARKER = "__isToolDefinition"; const LEGACY_BUILTIN_TOOL_MARKER = "__ompLegacyBuiltinTool"; @@ -1434,4 +1434,4 @@ export { parseArgs } from "../cli/args"; export * from "../index"; export { formatBytes as formatSize } from "../tools/render-utils"; export { copyToClipboard } from "../utils/clipboard"; -export { Type } from "./typebox"; +export { Type } from "./legacy-typebox"; diff --git a/packages/coding-agent/src/extensibility/legacy-typebox.ts b/packages/coding-agent/src/extensibility/legacy-typebox.ts new file mode 100644 index 000000000..7270aaebd --- /dev/null +++ b/packages/coding-agent/src/extensibility/legacy-typebox.ts @@ -0,0 +1,101 @@ +import { + type ObjectOpts, + Type as OmpType, + type TypeBuilder as OmpTypeBuilder, + type TUnsafe, +} from "@oh-my-pi/omptype/typebox"; +import { upgradeJsonSchemaTo202012, validateJsonSchemaValue } from "@oh-my-pi/pi-ai/utils/schema"; + +export * from "@oh-my-pi/omptype/typebox"; + +const VALIDATION_FAILURE = Symbol("pi.typebox.validationFailure"); + +interface ValidationFailure { + message: string; + readonly [VALIDATION_FAILURE]: true; +} + +interface SafeParseSuccess { + success: true; + data: T; +} + +interface SafeParseFailure { + success: false; + error: ValidationFailure; +} + +type LegacyUnsafeSchema = Record & + TUnsafe & { + __validator(data: unknown): T | ValidationFailure; + safeParse(input: unknown): SafeParseSuccess | SafeParseFailure; + }; + +function defineHidden(target: object, key: PropertyKey, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + configurable: true, + }); +} + +function unsafe(jsonSchema: Record = {}): LegacyUnsafeSchema { + const schema = { ...jsonSchema } as LegacyUnsafeSchema; + const upgradedSchema = upgradeJsonSchemaTo202012(jsonSchema); + const validate = (data: unknown): T | ValidationFailure => { + const result = validateJsonSchemaValue(upgradedSchema, data); + if (result.success) return data as T; + let message = ""; + for (const issue of result.issues) { + if (message) message += "; "; + message += issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message; + } + const failure = { message: message || "Invalid value" } as ValidationFailure; + defineHidden(failure, VALIDATION_FAILURE, true); + return failure; + }; + defineHidden(schema, "__validator", validate); + defineHidden(schema, "safeParse", (input: unknown): SafeParseSuccess | SafeParseFailure => { + const result = validate(input); + return typeof result === "object" && result !== null && VALIDATION_FAILURE in result + ? { success: false, error: result } + : { success: true, data: result as T }; + }); + return schema; +} + +const object = ((properties: Record, opts?: ObjectOpts) => { + let hasRawProperty = false; + for (const key in properties) { + if (typeof properties[key] !== "function") { + hasRawProperty = true; + break; + } + } + if (!hasRawProperty) return OmpType.Object(properties as Parameters[0], opts); + + const propertySchemas: Record = {}; + const required: string[] = []; + for (const key in properties) { + const property = properties[key]; + propertySchemas[key] = + typeof property === "function" && "toJsonSchema" in property + ? (property as { toJsonSchema(): Record }).toJsonSchema() + : property; + required.push(key); + } + const document: Record = { type: "object", properties: propertySchemas, required }; + if (opts?.additionalProperties !== undefined) { + document.additionalProperties = + typeof opts.additionalProperties === "function" + ? opts.additionalProperties.toJsonSchema() + : opts.additionalProperties; + } + return unsafe(document); +}) as typeof OmpType.Object; + +export const Type: OmpTypeBuilder = { ...OmpType, Object: object, Unsafe: unsafe } as unknown as OmpTypeBuilder; +export type TypeBuilder = OmpTypeBuilder; + +const legacyTypeBox: { Type: OmpTypeBuilder } = { Type }; +export default legacyTypeBox; diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index 6f28d3fcb..b4db0f03b 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -682,13 +682,11 @@ function clearLegacyPiResolutionCaches(): void { registerPluginCacheInvalidator(clearLegacyPiResolutionCaches); const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded"); -// Extensions that imported TypeBox directly used to resolve against a real -// `@sinclair/typebox` or `typebox` install. The runtime dep was replaced with -// the Zod-backed shim under `extensibility/typebox.ts`; plugins still importing -// either public name are redirected to that shim so existing extensions keep -// working without code changes. Submodules like `@sinclair/typebox/compiler` -// are intentionally not remapped — those expose TypeBox-only APIs the shim does -// not provide and plugins relying on them must vendor TypeBox directly. +// Extensions importing TypeBox directly are redirected to omptype's TypeBox +// facade, keeping legacy builders while producing callable omptype schemas at +// the tool wire boundary. Submodules such as `@sinclair/typebox/compiler` are +// intentionally not remapped: plugins relying on those TypeBox-only APIs must +// vendor TypeBox directly. const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/; // Compat-shim path resolution. In compiled-binary mode every bundled surface @@ -736,20 +734,13 @@ function sourceShimPath(file: string): string { } /** - * Resolve the path the TypeBox compatibility shim ships at, then drop it when - * the source file is missing. + * Resolve the coding-agent compatibility surface that composes omptype's + * TypeBox facade with legacy `Type.Unsafe`, then drop the remap when that + * entrypoint is missing. * - * In compiled-binary mode the shim is served through the - * `omp-legacy-pi-bundled:` virtual namespace (issue #3423) — bunfs paths are - * unreachable on Bun 1.3.14+, so the virtual specifier is always available and - * needs no filesystem probe. In dev / source-link / installed-package mode the - * shim is an on-disk source file; validation mirrors - * `__validateLegacyPiPackageRootOverrides` (#2168): if the computed candidate - * doesn't exist (e.g. an install that dropped the source — issue #3414), - * `resolveTypeBoxSpecifier` returns `undefined` and - * `rewriteLegacyExtensionSource` leaves bare `typebox` / `@sinclair/typebox` - * specifiers alone, so Bun falls through to native resolution against the - * extension's own `node_modules`. + * In compiled-binary mode the surface is served through the + * `omp-legacy-pi-bundled:` virtual namespace (issue #3423). Dev, source-link, + * and installed-package modes use the shipped source module. * * Exported for tests; production callers use `TYPEBOX_SHIM_PATH`. */ @@ -764,14 +755,14 @@ export function __resolveTypeBoxShimPath( return pathExistsSync(sourcePath) ? sourcePath : null; } -const TYPEBOX_SHIM_PATH = __resolveTypeBoxShimPath(IS_COMPILED_BINARY, sourceShimPath("typebox.ts")); +const TYPEBOX_SHIM_PATH = __resolveTypeBoxShimPath(IS_COMPILED_BINARY, sourceShimPath("legacy-typebox.ts")); // Legacy extensions historically imported `Type` (and `Static`/`TSchema`) from // the package root of `@(scope)/pi-ai`. pi-ai 15.1.0 removed the runtime `Type` // export (see `packages/ai/CHANGELOG.md`), so the bare canonical specifier no // longer satisfies those imports. The override below redirects only the bare // pi-ai package root onto a sibling shim that re-exports the canonical surface -// plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath +// plus the borrowed `Type` runtime from the omptype TypeBox facade. Subpath // imports such as `@oh-my-pi/pi-ai/oauth` continue to resolve directly // against the bundled pi-ai package. const LEGACY_PI_AI_SHIM_PATH = IS_COMPILED_BINARY diff --git a/packages/coding-agent/src/extensibility/typebox.ts b/packages/coding-agent/src/extensibility/typebox.ts deleted file mode 100644 index fd047ef13..000000000 --- a/packages/coding-agent/src/extensibility/typebox.ts +++ /dev/null @@ -1,958 +0,0 @@ -/** - * Minimal `@sinclair/typebox` runtime compatibility shim. - * - * Historically the coding agent injected the real `@sinclair/typebox` (~5MB - * dependency) into extensions, hooks, custom tools, and custom commands so - * they could author parameter schemas as `Type.Object({ name: Type.String() })`. - * - * This module provides the subset those integrations depend on: - * - * - TypeBox-style `Type.*` builders. - * - Runtime validation through `schema.safeParse(input)` and `schema.__validator(input)`. - * - Enumerable JSON Schema keywords so `{ ...schema }`, `JSON.stringify(schema)`, - * and `toolWireSchema({ parameters: schema })` all see the same schema. - * - * Internal validator fields and methods are intentionally non-enumerable. The - * object should look like JSON Schema at every serialization/wire boundary and - * like a small validator at runtime. - */ - -import { areJsonValuesEqual, upgradeJsonSchemaTo202012, validateJsonSchemaValue } from "@oh-my-pi/pi-ai/utils/schema"; - -// --------------------------------------------------------------------------- -// Type aliases — exported so `import type { Static, TSchema } from "..."` -// patterns keep compiling at the call site. -// --------------------------------------------------------------------------- - -export type TSchema = ArkSchema; -export type Static = T["__infer"]; -export type TAny = ArkSchema; -export type TUnknown = ArkSchema; -export type TNever = ArkSchema; -export type TNull = ArkSchema; -export type TString = ArkSchema; -export type TNumber = ArkSchema; -export type TInteger = ArkSchema; -export type TBoolean = ArkSchema; -export type TLiteral<_V extends string | number | boolean> = ArkSchema; -export type TArray<_E extends ArkSchema> = ArkSchema; -export type TObject<_P extends Record = Record> = ArkSchema; -export type TOptional<_E extends ArkSchema> = ArkSchema; -export type TUnion<_T extends readonly ArkSchema[] = readonly ArkSchema[]> = ArkSchema; -export type TEnum<_T extends readonly (string | number)[] = readonly (string | number)[]> = ArkSchema; -export type TRecord<_K extends ArkSchema, _V extends ArkSchema> = ArkSchema; -/** TypeBox-compatible wrapper for raw JSON Schema documents. */ -export type TUnsafe<_T = unknown> = ArkSchema; - -// --------------------------------------------------------------------------- -// ArkSchema wrapper — JSON Schema object with hidden validator metadata -// --------------------------------------------------------------------------- - -const VALIDATION_FAILURE = Symbol("pi.typebox.validationFailure"); - -interface ValidationFailure { - message: string; - readonly [VALIDATION_FAILURE]: true; -} - -interface SafeParseSuccess { - success: true; - data: unknown; -} - -interface SafeParseFailure { - success: false; - error: ValidationFailure; -} - -interface SchemaInternals { - optional?: boolean; - metadata?: Record; - properties?: Record; - additionalProperties?: boolean | ArkSchema; - inner?: ArkSchema; -} - -/** - * JSON-Schema-shaped object with non-enumerable runtime helpers. - * Validators return the validated data or a marked `{ message }` failure. - */ -interface ArkSchema { - __validator: (data: unknown) => unknown; - __metadata?: Record; - __optional?: true; - __properties?: Record; - __additionalProperties?: boolean | ArkSchema; - __infer?: unknown; - __inner?: ArkSchema; - safeParse(input: unknown): SafeParseSuccess | SafeParseFailure; - toJSON(): Record; - [key: string]: unknown; -} - -function defineHidden(target: object, key: PropertyKey, value: unknown): void { - Object.defineProperty(target, key, { - value, - writable: true, - configurable: true, - }); -} - -function validationFailure(message: string): ValidationFailure { - const failure = { message } as ValidationFailure; - defineHidden(failure, VALIDATION_FAILURE, true); - return failure; -} - -function isValidationFailure(value: unknown): value is ValidationFailure { - if (value === null || typeof value !== "object") return false; - const candidate = value as { readonly [VALIDATION_FAILURE]?: unknown }; - return candidate[VALIDATION_FAILURE] === true; -} - -function schemaJsonValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map(schemaJsonValue); - if (value === null || typeof value !== "object") return value; - const result: Record = {}; - for (const key in value) { - result[key] = schemaJsonValue((value as Record)[key]); - } - return result; -} - -function jsonSchemaOf(schema: ArkSchema): Record { - const result: Record = {}; - for (const key in schema) result[key] = schemaJsonValue(schema[key]); - return result; -} - -function createArkSchema( - validator: (data: unknown) => unknown, - jsonSchema: Record = {}, - internals: SchemaInternals = {}, -): ArkSchema { - const schema = { ...jsonSchema } as ArkSchema; - const metadata = internals.metadata ?? {}; - defineHidden(schema, "__validator", validator); - defineHidden(schema, "__metadata", metadata); - if (internals.optional) defineHidden(schema, "__optional", true); - if (internals.properties) defineHidden(schema, "__properties", internals.properties); - if (internals.additionalProperties !== undefined) { - defineHidden(schema, "__additionalProperties", internals.additionalProperties); - } - if (internals.inner) defineHidden(schema, "__inner", internals.inner); - defineHidden(schema, "safeParse", (input: unknown): SafeParseSuccess | SafeParseFailure => { - const result = validator(input); - if (isValidationFailure(result)) return { success: false, error: result }; - return { success: true, data: result }; - }); - defineHidden(schema, "toJSON", () => jsonSchemaOf(schema)); - return schema; -} - -function cloneSchemaWith( - schema: ArkSchema, - jsonSchema: Record, - internals?: SchemaInternals, -): ArkSchema { - return createArkSchema(schema.__validator, jsonSchema, { - metadata: internals?.metadata ?? schema.__metadata, - optional: internals?.optional ?? schema.__optional === true, - properties: internals?.properties ?? schema.__properties, - additionalProperties: internals?.additionalProperties ?? schema.__additionalProperties, - inner: internals?.inner ?? schema.__inner, - }); -} - -function withMetadata(schema: ArkSchema, newMeta: Record): ArkSchema { - return cloneSchemaWith( - schema, - { ...jsonSchemaOf(schema), ...newMeta }, - { - metadata: { ...(schema.__metadata ?? {}), ...newMeta }, - }, - ); -} - -// --------------------------------------------------------------------------- -// Option shapes — loose subset of JSON Schema metadata + per-type constraints. -// --------------------------------------------------------------------------- - -interface Meta { - title?: string; - description?: string; - default?: unknown; - examples?: unknown[]; - // Real TypeBox accepts arbitrary extra JSON Schema keywords; we tolerate - // them silently so callers don't blow up on niche metadata. - [key: string]: unknown; -} - -interface StringOpts extends Meta { - minLength?: number; - maxLength?: number; - pattern?: string; - format?: string; -} - -interface NumberOpts extends Meta { - minimum?: number; - maximum?: number; - exclusiveMinimum?: number; - exclusiveMaximum?: number; - multipleOf?: number; -} - -interface ArrayOpts extends Meta { - minItems?: number; - maxItems?: number; - uniqueItems?: boolean; -} - -interface ObjectOpts extends Meta { - /** - * TypeBox default: extra keys are preserved. Set `false` to reject unknowns, - * `true` to allow any, or a schema to validate them. - */ - additionalProperties?: boolean | ArkSchema; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function applyMeta(schema: ArkSchema, opts: Meta | undefined): ArkSchema { - if (!opts) return schema; - const metadata: Record = {}; - for (const key in opts) { - if (key === "additionalProperties") continue; - metadata[key] = opts[key]; - } - return withMetadata(schema, metadata); -} - -function createStringValidator( - baseValidator: (data: unknown) => unknown, - opts?: StringOpts, -): (data: unknown) => unknown { - return (data: unknown) => { - const result = baseValidator(data); - if (isValidationFailure(result)) return result; - if (typeof result !== "string") return validationFailure("Expected string"); - if (opts?.minLength !== undefined && result.length < opts.minLength) { - return validationFailure(`String must have at least ${opts.minLength} characters`); - } - if (opts?.maxLength !== undefined && result.length > opts.maxLength) { - return validationFailure(`String must have at most ${opts.maxLength} characters`); - } - if (opts?.pattern !== undefined) { - const regex = new RegExp(opts.pattern); - if (!regex.test(result)) return validationFailure(`String must match pattern ${opts.pattern}`); - } - return result; - }; -} - -function createFormatStringValidator(format: string): (data: unknown) => unknown { - return (data: unknown) => { - if (typeof data !== "string") return validationFailure("Expected string"); - switch (format) { - case "email": { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(data) ? data : validationFailure("Invalid email format"); - } - case "url": - case "uri": - try { - new URL(data); - return data; - } catch { - return validationFailure("Invalid URL format"); - } - case "uuid": { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - return uuidRegex.test(data) ? data : validationFailure("Invalid UUID format"); - } - case "date": { - const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - if (!dateRegex.test(data)) return validationFailure("Invalid date format (YYYY-MM-DD)"); - const date = new Date(data); - return Number.isNaN(date.getTime()) ? validationFailure("Invalid date") : data; - } - case "date-time": { - const dateTime = new Date(data); - return Number.isNaN(dateTime.getTime()) ? validationFailure("Invalid date-time format") : data; - } - case "time": { - const timeRegex = /^\d{2}:\d{2}:\d{2}(.\d{3})?([+-]\d{2}:\d{2}|Z)?$/; - return timeRegex.test(data) ? data : validationFailure("Invalid time format"); - } - case "ipv4": { - const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; - if (!ipv4Regex.test(data)) return validationFailure("Invalid IPv4 format"); - const parts = data.split(".").map(Number); - return parts.some(part => part > 255) ? validationFailure("Invalid IPv4 address") : data; - } - case "ipv6": { - const ipv6Regex = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/i; - return ipv6Regex.test(data) ? data : validationFailure("Invalid IPv6 format"); - } - default: - return data; - } - }; -} - -function createNumberValidator(isInteger = false): (data: unknown) => unknown { - return (data: unknown) => { - if (typeof data !== "number" || Number.isNaN(data)) { - return validationFailure(`Expected ${isInteger ? "integer" : "number"}`); - } - if (isInteger && !Number.isInteger(data)) return validationFailure("Expected integer"); - return data; - }; -} - -function createConstrainedNumberValidator( - baseValidator: (data: unknown) => unknown, - opts?: NumberOpts, -): (data: unknown) => unknown { - return (data: unknown) => { - const result = baseValidator(data); - if (isValidationFailure(result)) return result; - if (typeof result !== "number") return validationFailure("Expected number"); - if (opts?.minimum !== undefined && result < opts.minimum) { - return validationFailure(`Number must be at least ${opts.minimum}`); - } - if (opts?.maximum !== undefined && result > opts.maximum) { - return validationFailure(`Number must be at most ${opts.maximum}`); - } - if (opts?.exclusiveMinimum !== undefined && result <= opts.exclusiveMinimum) { - return validationFailure(`Number must be greater than ${opts.exclusiveMinimum}`); - } - if (opts?.exclusiveMaximum !== undefined && result >= opts.exclusiveMaximum) { - return validationFailure(`Number must be less than ${opts.exclusiveMaximum}`); - } - if (opts?.multipleOf !== undefined && result % opts.multipleOf !== 0) { - return validationFailure(`Number must be a multiple of ${opts.multipleOf}`); - } - return result; - }; -} - -function createArrayValidator(itemValidator: ArkSchema, opts?: ArrayOpts): (data: unknown) => unknown { - return (data: unknown) => { - if (!Array.isArray(data)) return validationFailure("Expected array"); - if (opts?.minItems !== undefined && data.length < opts.minItems) { - return validationFailure(`Array must have at least ${opts.minItems} items`); - } - if (opts?.maxItems !== undefined && data.length > opts.maxItems) { - return validationFailure(`Array must have at most ${opts.maxItems} items`); - } - if (opts?.uniqueItems === true) { - for (let i = 0; i < data.length; i++) { - for (let j = i + 1; j < data.length; j++) { - if (areJsonValuesEqual(data[i], data[j])) return validationFailure("Array items must be unique"); - } - } - } - const itemValidatorFn = itemValidator.__validator; - for (let i = 0; i < data.length; i++) { - const itemResult = itemValidatorFn(data[i]); - if (isValidationFailure(itemResult)) { - return validationFailure(`Item at index ${i}: ${itemResult.message || "Invalid"}`); - } - } - return data; - }; -} - -function createTupleValidator(itemSchemas: ArkSchema[]): (data: unknown) => unknown { - return (data: unknown) => { - if (!Array.isArray(data)) return validationFailure("Expected array"); - if (data.length !== itemSchemas.length) { - return validationFailure(`Expected tuple of length ${itemSchemas.length}, got ${data.length}`); - } - for (let i = 0; i < itemSchemas.length; i++) { - const itemValidator = itemSchemas[i].__validator; - const itemResult = itemValidator(data[i]); - if (isValidationFailure(itemResult)) { - return validationFailure(`Item at index ${i}: ${itemResult.message || "Invalid"}`); - } - } - return data; - }; -} - -function createObjectValidator(properties: Record, opts?: ObjectOpts): (data: unknown) => unknown { - return (data: unknown) => { - if (!data || typeof data !== "object") return validationFailure("Expected object"); - const obj = data as Record; - const result: Record = {}; - - const keys = new Set(); - for (const key in obj) { - keys.add(key); - } - - for (const key in properties) { - const schema = properties[key]; - const validated = schema.__validator(obj[key]); - if (isValidationFailure(validated)) { - return validationFailure(`Property ${key}: ${validated.message || "Invalid"}`); - } - if (obj[key] !== undefined || schema.__optional !== true) { - result[key] = validated; - } - keys.delete(key); - } - - const additionalProperties = opts?.additionalProperties; - if (additionalProperties === false) { - if (keys.size > 0) return validationFailure(`Unexpected properties: ${Array.from(keys).join(", ")}`); - } else if (additionalProperties === true || additionalProperties === undefined) { - for (const key of keys) result[key] = obj[key]; - } else { - const additionalValidator = additionalProperties.__validator; - for (const key of keys) { - const validated = additionalValidator(obj[key]); - if (isValidationFailure(validated)) { - return validationFailure(`Property ${key}: ${validated.message || "Invalid"}`); - } - result[key] = validated; - } - } - return result; - }; -} - -function createUnionValidator(schemas: ArkSchema[]): (data: unknown) => unknown { - return (data: unknown) => { - if (schemas.length === 0) return validationFailure("Cannot validate empty union"); - const errors: string[] = []; - for (const schema of schemas) { - const result = schema.__validator(data); - if (!isValidationFailure(result)) return result; - errors.push(result.message || "Invalid"); - } - return validationFailure(`Failed all union options: ${errors.join("; ")}`); - }; -} - -function createIntersectionValidator(schemas: ArkSchema[]): (data: unknown) => unknown { - return (data: unknown) => { - let result = data; - for (const schema of schemas) { - result = schema.__validator(result); - if (isValidationFailure(result)) return result; - } - return result; - }; -} - -function createOptionalValidator(schema: ArkSchema): (data: unknown) => unknown { - const baseValidator = schema.__validator; - return (data: unknown) => (data === undefined ? undefined : baseValidator(data)); -} - -function createNullableValidator(schema: ArkSchema): (data: unknown) => unknown { - const baseValidator = schema.__validator; - return (data: unknown) => (data === null ? null : baseValidator(data)); -} - -function createRecordValidator(keySchema: ArkSchema, valueSchema: ArkSchema): (data: unknown) => unknown { - return (data: unknown) => { - if (!data || typeof data !== "object") return validationFailure("Expected object"); - const obj = data as Record; - const result: Record = {}; - const keyValidator = keySchema.__validator; - const valueValidator = valueSchema.__validator; - for (const key in obj) { - const value = obj[key]; - const keyResult = keyValidator(key); - if (isValidationFailure(keyResult)) { - return validationFailure(`Key ${key}: ${keyResult.message || "Invalid"}`); - } - const valueResult = valueValidator(value); - if (isValidationFailure(valueResult)) { - return validationFailure(`Key ${key}: ${valueResult.message || "Invalid"}`); - } - result[key] = valueResult; - } - return result; - }; -} - -function isArrayIndexKey(key: string): boolean { - if (!/^(?:0|[1-9]\d*)$/.test(key)) return false; - const index = Number(key); - return Number.isSafeInteger(index) && index >= 0; -} - -function uniqueLiteralValues(values: readonly (string | number | boolean)[]): Array { - const unique: Array = []; - for (const value of values) { - if (!unique.some(existing => existing === value)) unique.push(value); - } - return unique; -} - -function requiredKeys(properties: Record): string[] | undefined { - const required: string[] = []; - for (const key in properties) { - if (properties[key].__optional !== true) { - required.push(key); - } - } - return required.length === 0 ? undefined : required; -} - -function objectJsonSchema(properties: Record, opts?: ObjectOpts): Record { - const propertySchemas: Record = {}; - for (const key in properties) propertySchemas[key] = jsonSchemaOf(properties[key]); - const schema: Record = { type: "object", properties: propertySchemas }; - const required = requiredKeys(properties); - if (required) schema.required = required; - const additionalProperties = opts?.additionalProperties; - if (additionalProperties !== undefined) { - schema.additionalProperties = - typeof additionalProperties === "boolean" ? additionalProperties : jsonSchemaOf(additionalProperties); - } - return schema; -} - -function schemaWithoutOptional(schema: ArkSchema): ArkSchema { - const base = schema.__inner ?? schema; - return createArkSchema(base.__validator, jsonSchemaOf(base), { - metadata: base.__metadata, - properties: base.__properties, - additionalProperties: base.__additionalProperties, - }); -} - -// --------------------------------------------------------------------------- -// Builders -// --------------------------------------------------------------------------- - -function tString(opts?: StringOpts): ArkSchema { - let validator: (data: unknown) => unknown = opts?.format - ? createFormatStringValidator(opts.format) - : (data: unknown) => (typeof data === "string" ? data : validationFailure("Expected string")); - validator = createStringValidator(validator, opts); - return applyMeta(createArkSchema(validator, { type: "string" }), opts); -} - -function tNumber(opts?: NumberOpts): ArkSchema { - const validator = createConstrainedNumberValidator(createNumberValidator(false), opts); - return applyMeta(createArkSchema(validator, { type: "number" }), opts); -} - -function tInteger(opts?: NumberOpts): ArkSchema { - const validator = createConstrainedNumberValidator(createNumberValidator(true), opts); - return applyMeta(createArkSchema(validator, { type: "integer" }), opts); -} - -function tBoolean(opts?: Meta): ArkSchema { - const validator = (data: unknown) => (typeof data === "boolean" ? data : validationFailure("Expected boolean")); - return applyMeta(createArkSchema(validator, { type: "boolean" }), opts); -} - -function tNull(opts?: Meta): ArkSchema { - const validator = (data: unknown) => (data === null ? data : validationFailure("Expected null")); - return applyMeta(createArkSchema(validator, { type: "null" }), opts); -} - -const identityValidator = (data: unknown): unknown => data; - -function tAny(opts?: Meta): ArkSchema { - return applyMeta(createArkSchema(identityValidator, {}), opts); -} - -function tUnknown(opts?: Meta): ArkSchema { - return applyMeta(createArkSchema(identityValidator, {}), opts); -} - -function tNever(opts?: Meta): ArkSchema { - return applyMeta( - createArkSchema(() => validationFailure("Never type does not accept any value"), { not: {} }), - opts, - ); -} - -function tLiteral(value: V, opts?: Meta): ArkSchema { - const validator = (data: unknown) => - data === value ? data : validationFailure(`Expected literal ${JSON.stringify(value)}`); - return applyMeta(createArkSchema(validator, { const: value }), opts); -} - -function tUnion(schemas: T, opts?: Meta): ArkSchema { - if (schemas.length === 0) - return applyMeta( - createArkSchema(() => validationFailure("Empty union"), { not: {} }), - opts, - ); - if (schemas.length === 1) return applyMeta(schemas[0], opts); - const validator = createUnionValidator([...schemas]); - return applyMeta(createArkSchema(validator, { anyOf: schemas.map(jsonSchemaOf) }), opts); -} - -function tIntersect(schemas: readonly ArkSchema[], opts?: Meta): ArkSchema { - if (schemas.length === 0) - return applyMeta( - createArkSchema((data: unknown) => data, {}), - opts, - ); - if (schemas.length === 1) return applyMeta(schemas[0], opts); - const validator = createIntersectionValidator([...schemas]); - return applyMeta(createArkSchema(validator, { allOf: schemas.map(jsonSchemaOf) }), opts); -} - -function literalUnion(values: readonly (string | number | boolean)[], opts?: Meta): ArkSchema { - const unique = uniqueLiteralValues(values); - if (unique.length === 0) - return applyMeta( - createArkSchema(() => validationFailure("Empty literal union"), { not: {} }), - opts, - ); - if (unique.length === 1) return tLiteral(unique[0] as string | number | boolean, opts); - const validator = (data: unknown) => { - for (const value of unique) if (data === value) return data; - return validationFailure(`Expected one of: ${unique.join(", ")}`); - }; - return applyMeta(createArkSchema(validator, { enum: unique }), opts); -} - -function tEnum | readonly (string | number)[]>( - values: T, - opts?: Meta, -): ArkSchema { - let list: (string | number)[]; - if (Array.isArray(values)) { - list = values; - } else { - list = []; - for (const key in values) { - const value = values[key]; - if (!(isArrayIndexKey(key) && typeof value === "string")) { - list.push(value as string | number); - } - } - } - return literalUnion(list, opts); -} - -function tArray(item: E, opts?: ArrayOpts): ArkSchema { - const validator = createArrayValidator(item, opts); - return applyMeta(createArkSchema(validator, { type: "array", items: jsonSchemaOf(item) }), opts); -} - -function tTuple(items: readonly ArkSchema[], opts?: Meta): ArkSchema { - const validator = createTupleValidator([...items]); - return applyMeta( - createArkSchema(validator, { - type: "array", - prefixItems: items.map(jsonSchemaOf), - minItems: items.length, - maxItems: items.length, - }), - opts, - ); -} - -function tObject

>(properties: P, opts?: ObjectOpts): ArkSchema { - const props = properties as Record; - const validator = createObjectValidator(props, opts); - const schema = createArkSchema(validator, objectJsonSchema(props, opts), { - properties: props, - additionalProperties: opts?.additionalProperties, - }); - return applyMeta(schema, opts); -} - -function literalRecordKeys(keySchema: ArkSchema): string[] | null { - const json = jsonSchemaOf(keySchema); - if ("const" in json) return [String(json.const)]; - const values = json.enum; - if (!Array.isArray(values)) return null; - const keys: string[] = []; - for (const value of values) { - const type = typeof value; - if (type !== "string" && type !== "number" && type !== "boolean") return null; - keys.push(String(value)); - } - return keys; -} - -function createRecordJson(keySchema: ArkSchema, valueSchema: ArkSchema): Record { - const valueJson = jsonSchemaOf(valueSchema); - const keys = literalRecordKeys(keySchema); - if (keys) { - const properties: Record = {}; - for (const key of keys) properties[key] = valueJson; - return { type: "object", properties, required: keys, additionalProperties: false }; - } - const json: Record = { type: "object", additionalProperties: valueJson }; - const keyJson = jsonSchemaOf(keySchema); - if (Object.keys(keyJson).length > 0) json.propertyNames = keyJson; - return json; -} - -function tRecord(key: K, value: V, opts?: Meta): ArkSchema { - const validator = createRecordValidator(key, value); - return applyMeta(createArkSchema(validator, createRecordJson(key, value)), opts); -} - -function tOptional(schema: E, opts?: Meta): ArkSchema { - const validator = createOptionalValidator(schema); - const optional = createArkSchema(validator, jsonSchemaOf(schema), { - optional: true, - inner: schema, - }); - return applyMeta(optional, opts); -} - -function tNullable(schema: E, opts?: Meta): ArkSchema { - const validator = createNullableValidator(schema); - return applyMeta(createArkSchema(validator, { anyOf: [jsonSchemaOf(schema), { type: "null" }] }), opts); -} - -function tReadonly(schema: E): ArkSchema { - // TypeBox's `Type.Readonly` is purely a marker; runtime validation is identical. - return schema; -} - -function tPartial<_P extends Record>(obj: ArkSchema): ArkSchema { - if (obj.__properties) { - const properties: Record = {}; - for (const key in obj.__properties) { - const schema = obj.__properties[key]; - properties[key] = schema.__optional === true ? schema : tOptional(schema); - } - return tObject(properties, { additionalProperties: obj.__additionalProperties }); - } - const objValidator = obj.__validator; - const metadata = jsonSchemaOf(obj); - delete metadata.required; - return createArkSchema(objValidator, metadata); -} - -function tRequired<_P extends Record>(obj: ArkSchema): ArkSchema { - if (obj.__properties) { - const properties: Record = {}; - for (const key in obj.__properties) { - properties[key] = schemaWithoutOptional(obj.__properties[key]); - } - return tObject(properties, { additionalProperties: obj.__additionalProperties }); - } - const metadata = jsonSchemaOf(obj); - if (metadata.properties && typeof metadata.properties === "object" && !Array.isArray(metadata.properties)) { - const properties = metadata.properties as Record; - const required: string[] = []; - for (const key in properties) { - required.push(key); - } - metadata.required = required; - } - return createArkSchema(obj.__validator, metadata); -} - -function tPick

, K extends keyof P>(obj: ArkSchema, keys: readonly K[]): ArkSchema { - const keySet = new Set([...keys].map(String)); - if (obj.__properties) { - const properties: Record = {}; - for (const key of keySet) { - const schema = obj.__properties[key]; - if (schema) properties[key] = schema; - } - return tObject(properties, { additionalProperties: obj.__additionalProperties }); - } - const validator = (data: unknown) => { - if (!data || typeof data !== "object") { - return validationFailure("Expected object"); - } - - const result: Record = {}; - const obj_data = data as Record; - - for (const key of keySet) { - if (key in obj_data) { - result[key] = obj_data[key]; - } - } - - return result; - }; - - const metadata = jsonSchemaOf(obj); - if (metadata.properties && typeof metadata.properties === "object" && !Array.isArray(metadata.properties)) { - const properties = metadata.properties as Record; - const filteredProps: Record = {}; - for (const key in properties) { - if (keySet.has(key)) { - filteredProps[key] = properties[key]; - } - } - metadata.properties = filteredProps; - } - if (Array.isArray(metadata.required)) { - metadata.required = metadata.required.filter(key => typeof key === "string" && keySet.has(key)); - } - return createArkSchema(validator, metadata); -} - -function tOmit

, K extends keyof P>(obj: ArkSchema, keys: readonly K[]): ArkSchema { - const keySet = new Set([...keys].map(String)); - if (obj.__properties) { - const properties: Record = {}; - for (const key in obj.__properties) { - if (!keySet.has(key)) { - properties[key] = obj.__properties[key]; - } - } - return tObject(properties, { additionalProperties: obj.__additionalProperties }); - } - const validator = (data: unknown) => { - if (!data || typeof data !== "object") { - return validationFailure("Expected object"); - } - - const result: Record = {}; - const obj_data = data as Record; - - for (const key in obj_data) { - if (!keySet.has(key)) { - result[key] = obj_data[key]; - } - } - - return result; - }; - - const metadata = jsonSchemaOf(obj); - if (metadata.properties && typeof metadata.properties === "object" && !Array.isArray(metadata.properties)) { - const properties = metadata.properties as Record; - const filteredProps: Record = {}; - for (const key in properties) { - if (!keySet.has(key)) { - filteredProps[key] = properties[key]; - } - } - metadata.properties = filteredProps; - } - if (Array.isArray(metadata.required)) { - metadata.required = metadata.required.filter(key => typeof key === "string" && !keySet.has(key)); - } - return createArkSchema(validator, metadata); -} - -function tComposite(objects: readonly ArkSchema[], opts?: Meta): ArkSchema { - // Composite flattens object schemas into one - if (objects.length === 0) { - return applyMeta( - createArkSchema( - (data: unknown) => (data && typeof data === "object" ? data : validationFailure("Expected object")), - { - type: "object", - }, - ), - opts, - ); - } - - if (objects.length === 1) { - return applyMeta(objects[0], opts); - } - - let canFlatten = true; - const properties: Record = {}; - for (const schema of objects) { - if (!schema.__properties) { - canFlatten = false; - break; - } - for (const key in schema.__properties) { - properties[key] = schema.__properties[key]; - } - } - if (canFlatten) return tObject(properties, opts as ObjectOpts | undefined); - - // Merge all object validators - const validator = (data: unknown) => { - if (!data || typeof data !== "object") { - return validationFailure("Expected object"); - } - - const result = {} as Record; - const obj_data = data as Record; - - for (const schema of objects) { - const schemaValidator = schema.__validator; - const schemaResult = schemaValidator(obj_data); - - if (isValidationFailure(schemaResult)) { - return schemaResult; - } - - if (typeof schemaResult === "object" && schemaResult !== null) { - for (const key in schemaResult) { - result[key] = (schemaResult as Record)[key]; - } - } - } - - return result; - }; - - return applyMeta(createArkSchema(validator, { allOf: objects.map(jsonSchemaOf) }), opts); -} - -// --------------------------------------------------------------------------- -// Public `Type` namespace -// --------------------------------------------------------------------------- - -export const Type = { - String: tString, - Number: tNumber, - Integer: tInteger, - Boolean: tBoolean, - Null: tNull, - Any: tAny, - Unknown: tUnknown, - Unsafe(jsonSchema: Record = {}): TUnsafe { - // Validate against the same draft-2020-12 upgrade the wire/tool-call - // path applies, so legacy draft-07 documents (tuple `items`, etc.) - // behave identically in `safeParse` and `validateToolArguments`. - const upgradedSchema = upgradeJsonSchemaTo202012(jsonSchema); - const validator = (data: unknown): unknown => { - const result = validateJsonSchemaValue(upgradedSchema, data); - if (result.success) return data; - const messages = result.issues.map(issue => - issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message, - ); - return validationFailure(messages.join("; ") || "Invalid value"); - }; - return createArkSchema(validator, jsonSchema); - }, - Never: tNever, - Literal: tLiteral, - Union: tUnion, - Intersect: tIntersect, - Enum: tEnum, - Array: tArray, - Tuple: tTuple, - Object: tObject, - Record: tRecord, - Optional: tOptional, - Nullable: tNullable, - Readonly: tReadonly, - Partial: tPartial, - Required: tRequired, - Pick: tPick, - Omit: tOmit, - Composite: tComposite, -} as const; - -export type TypeBuilder = typeof Type; - -/** Default namespace export so `import * as typebox from "./typebox"` still resolves the `Type` key. */ -export default { Type }; diff --git a/packages/coding-agent/src/goals/tools/goal-tool.ts b/packages/coding-agent/src/goals/tools/goal-tool.ts index 125f520b5..5a63a5813 100644 --- a/packages/coding-agent/src/goals/tools/goal-tool.ts +++ b/packages/coding-agent/src/goals/tools/goal-tool.ts @@ -1,8 +1,8 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { formatNumber, prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import type { RenderResultOptions } from "../../extensibility/custom-tools/types"; import type { Theme, ThemeColor } from "../../modes/theme/theme"; import goalDescription from "../../prompts/tools/goal.md" with { type: "text" }; diff --git a/packages/coding-agent/src/lsp/types.ts b/packages/coding-agent/src/lsp/types.ts index 62ae6e617..fd2c8a851 100644 --- a/packages/coding-agent/src/lsp/types.ts +++ b/packages/coding-agent/src/lsp/types.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { TOOL_TIMEOUTS } from "../tools/tool-timeouts"; // ============================================================================= diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 3dc838e18..f429c4c5e 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Effort } from "@oh-my-pi/pi-ai"; import { @@ -19,7 +20,6 @@ import type { TerminalAppearance, } from "@oh-my-pi/pi-tui"; import { adjustHsv, colorLuma, getCustomThemesDir, isEnoent, logger, relativeLuminance } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import chalk from "chalk"; import { LRUCache } from "lru-cache/raw"; // Embed theme JSON files at build time @@ -1118,7 +1118,7 @@ const spinnerFramesSchema = type("unknown").narrow((value): value is SpinnerFram const themeJsonSchema = type({ "$schema?": "string", name: "string", - "vars?": "Record", + "vars?": { "[string]": "string | number" }, colors: themeColorsSchema, "export?": { "pageBg?": "string | number", @@ -1127,7 +1127,7 @@ const themeJsonSchema = type({ }, "symbols?": { "preset?": "'unicode' | 'nerd' | 'ascii'", - "overrides?": "Record", + "overrides?": { "[string]": "string" }, "spinnerFrames?": spinnerFramesSchema, }, }); diff --git a/packages/coding-agent/src/security/contracts/schemas.ts b/packages/coding-agent/src/security/contracts/schemas.ts index 989a2c6e7..64953d643 100644 --- a/packages/coding-agent/src/security/contracts/schemas.ts +++ b/packages/coding-agent/src/security/contracts/schemas.ts @@ -1,11 +1,7 @@ +import { type } from "@oh-my-pi/omptype"; import { once } from "@oh-my-pi/pi-utils"; -import { scope } from "arktype"; export const getSecurityContractSchemas = once(() => { - // Security schemas validate only during security scans, so lazy construction - // with interpreted traversal avoids eager JIT startup tax without changing correctness. - const { type } = scope({}, { jitless: true }); - const stringRecordSchema = type({ "[string]": "string" }); const unknownRecordSchema = type({ "[string]": "unknown" }); diff --git a/packages/coding-agent/src/security/contracts/validation.ts b/packages/coding-agent/src/security/contracts/validation.ts index ce8fed55f..c3b5aa3c5 100644 --- a/packages/coding-agent/src/security/contracts/validation.ts +++ b/packages/coding-agent/src/security/contracts/validation.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import { getSecurityContractSchemas } from "./schemas"; import type { SecurityFinding, SecurityScan, SecurityScanBundle, SecurityScanPlan } from "./types"; diff --git a/packages/coding-agent/src/security/publication.ts b/packages/coding-agent/src/security/publication.ts index 9b2333948..a84667f20 100644 --- a/packages/coding-agent/src/security/publication.ts +++ b/packages/coding-agent/src/security/publication.ts @@ -1,4 +1,4 @@ -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; import type { ToolDefinition } from "../extensibility/extensions"; import securityPublishDescription from "../prompts/tools/security-publish.md" with { type: "text" }; import type { diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index d494908f6..3edc92696 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -1,6 +1,6 @@ +import { type BaseType, type } from "@oh-my-pi/omptype"; import type { Usage } from "@oh-my-pi/pi-ai"; import { $env } from "@oh-my-pi/pi-utils"; -import { type BaseType, type } from "arktype"; import type { AgentSessionEvent } from "../session/agent-session"; import type { ConfiguredThinkingLevel, TaskEffort } from "../thinking"; import type { NestedRepoPatch } from "./worktree"; diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 3770930fc..0730a207c 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -15,6 +15,7 @@ * - Questions may time out and auto-select the recommended option (configurable, disabled in plan mode) */ +import { type as arkType } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ToolExample } from "@oh-my-pi/pi-ai"; import { @@ -30,7 +31,6 @@ import { visibleWidth, } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type as arkType } from "arktype"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { ExtensionUISelectItem } from "../extensibility/extensions"; import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 675e0124e..4cd153349 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -1,12 +1,12 @@ import * as path from "node:path"; import { formatHashlineHeader } from "@oh-my-pi/hashline"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { replaceTabs, Text } from "@oh-my-pi/pi-tui"; import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store"; import { normalizeToLF } from "../edit/normalize"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 48e2f1ee4..dd39815c7 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -1,12 +1,12 @@ import * as path from "node:path"; import { formatHashlineHeader } from "@oh-my-pi/hashline"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 6f3caf196..776cbfcaa 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, @@ -9,7 +10,6 @@ import type { import type { Component } from "@oh-my-pi/pi-tui"; import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import type { Settings } from "../config/settings"; import { applyDirenvPreflight, type BashResult, executeBash } from "../exec/bash-executor"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index 2aa4f6231..188c2b9bc 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -1,7 +1,7 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ToolExample } from "@oh-my-pi/pi-ai"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import browserDescription from "../prompts/tools/browser.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { enforceInlineByteCap } from "../session/streaming-output"; diff --git a/packages/coding-agent/src/tools/checkpoint.ts b/packages/coding-agent/src/tools/checkpoint.ts index ba8ccfe33..8628b4975 100644 --- a/packages/coding-agent/src/tools/checkpoint.ts +++ b/packages/coding-agent/src/tools/checkpoint.ts @@ -1,6 +1,6 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import checkpointDescription from "../prompts/tools/checkpoint.md" with { type: "text" }; import rewindDescription from "../prompts/tools/rewind.md" with { type: "text" }; import type { ToolSession } from "."; diff --git a/packages/coding-agent/src/tools/computer.ts b/packages/coding-agent/src/tools/computer.ts index a6aa5e893..b481761c0 100644 --- a/packages/coding-agent/src/tools/computer.ts +++ b/packages/coding-agent/src/tools/computer.ts @@ -1,3 +1,4 @@ +import { type Type, type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, @@ -9,7 +10,6 @@ import type { Model } from "@oh-my-pi/pi-ai"; import { isClaudeModelId } from "@oh-my-pi/pi-catalog/identity"; import type { DesktopCapabilities } from "@oh-my-pi/pi-natives"; import { once, prompt } from "@oh-my-pi/pi-utils"; -import { type Type, type } from "arktype"; import { callSessionTool } from "../eval/js/tool-bridge"; import computerDescription from "../prompts/tools/computer.md" with { type: "text" }; import { enforceInlineByteCap } from "../session/streaming-output"; diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index ced6ffc6e..fce5bca93 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs/promises"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, @@ -10,7 +11,6 @@ import type { import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { isEnoent, prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { type DapBreakpointRecord, type DapCapabilities, diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index b3990b842..aa819d442 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -1,7 +1,7 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { jsBackend, juliaBackend, pythonBackend, rubyBackend } from "../eval"; import type { ExecutorBackend, ExecutorBackendResult } from "../eval/backend"; import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../eval/bridge-timeout"; diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 2ecc4afbe..12f86be1d 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, @@ -9,9 +10,7 @@ import type { AgentToolUpdateCallback, ToolApprovalDecision, } from "@oh-my-pi/pi-agent-core"; - import { getWorktreeDir, hashPath, isEnoent, logger, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import type { Settings } from "../config/settings"; import githubDescription from "../prompts/tools/github.md" with { type: "text" }; import * as git from "../utils/git"; diff --git a/packages/coding-agent/src/tools/glob.ts b/packages/coding-agent/src/tools/glob.ts index 5e86fc7ca..882205191 100644 --- a/packages/coding-agent/src/tools/glob.ts +++ b/packages/coding-agent/src/tools/glob.ts @@ -1,12 +1,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ToolExample } from "@oh-my-pi/pi-ai"; import * as natives from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { InternalUrlRouter } from "../internal-urls"; import { splitMemoryGlobPattern } from "../internal-urls/memory-protocol"; diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index ff25ecdad..d8076a8bc 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { formatHashlineHeader } from "@oh-my-pi/hashline"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, @@ -13,7 +14,6 @@ import { type GrepMatch, GrepOutputMode, type GrepResult, grep } from "@oh-my-pi import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { recordFileSnapshot, recordSeenLinesFromBody } from "../edit/file-snapshot-store"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { LocalProtocolOptions } from "../internal-urls/local-protocol"; diff --git a/packages/coding-agent/src/tools/hub/index.ts b/packages/coding-agent/src/tools/hub/index.ts index a23e25b99..1b3640b13 100644 --- a/packages/coding-agent/src/tools/hub/index.ts +++ b/packages/coding-agent/src/tools/hub/index.ts @@ -15,6 +15,7 @@ * when the agent has nothing else to do. */ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, @@ -25,7 +26,6 @@ import type { import type { ToolExample } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import type { RenderResultOptions } from "../../extensibility/custom-tools/types"; import { IrcBus } from "../../irc/bus"; import type { Theme } from "../../modes/theme/theme"; diff --git a/packages/coding-agent/src/tools/image-gen.ts b/packages/coding-agent/src/tools/image-gen.ts index d24c115e3..5e9a289c1 100644 --- a/packages/coding-agent/src/tools/image-gen.ts +++ b/packages/coding-agent/src/tools/image-gen.ts @@ -1,5 +1,6 @@ import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { type ApiKey, type FetchImpl, getEnvApiKey, type Model, withAuth } from "@oh-my-pi/pi-ai"; import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; import { @@ -20,7 +21,6 @@ import { Snowflake, untilAborted, } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import packageJson from "../../package.json" with { type: "json" }; import { isAuthenticated, type ModelRegistry } from "../config/model-registry"; import { settings } from "../config/settings"; diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index 3eed95364..96b709116 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -1,3 +1,4 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; import { @@ -9,7 +10,6 @@ import { type ToolExample, } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { extractTextContent } from "../commit/utils"; import { diff --git a/packages/coding-agent/src/tools/learn.ts b/packages/coding-agent/src/tools/learn.ts index faac481c0..74704a2b4 100644 --- a/packages/coding-agent/src/tools/learn.ts +++ b/packages/coding-agent/src/tools/learn.ts @@ -1,5 +1,5 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { type } from "arktype"; import { sanitizeSkillName, writeManagedSkill } from "../autolearn/managed-skills"; import { isNameClaimedByAuthoredSkill } from "../extensibility/skills"; import { localBackend } from "../memory-backend/local-backend"; diff --git a/packages/coding-agent/src/tools/manage-skill.ts b/packages/coding-agent/src/tools/manage-skill.ts index ae0e9a167..e9246b03c 100644 --- a/packages/coding-agent/src/tools/manage-skill.ts +++ b/packages/coding-agent/src/tools/manage-skill.ts @@ -1,6 +1,6 @@ import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { type } from "arktype"; import { deleteManagedSkill, getManagedSkillsDir, diff --git a/packages/coding-agent/src/tools/memory-edit.ts b/packages/coding-agent/src/tools/memory-edit.ts index 1ac505355..7fa3e1355 100644 --- a/packages/coding-agent/src/tools/memory-edit.ts +++ b/packages/coding-agent/src/tools/memory-edit.ts @@ -1,5 +1,5 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { type } from "arktype"; import memoryEditDescription from "../prompts/tools/memory-edit.md" with { type: "text" }; import type { ToolSession } from "."; diff --git a/packages/coding-agent/src/tools/memory-recall.ts b/packages/coding-agent/src/tools/memory-recall.ts index 8d013debf..bb4bebfef 100644 --- a/packages/coding-agent/src/tools/memory-recall.ts +++ b/packages/coding-agent/src/tools/memory-recall.ts @@ -1,6 +1,6 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { logger, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { formatCurrentTime, formatMemories } from "../hindsight/content"; import recallDescription from "../prompts/tools/recall.md" with { type: "text" }; import type { ToolSession } from "."; diff --git a/packages/coding-agent/src/tools/memory-reflect.ts b/packages/coding-agent/src/tools/memory-reflect.ts index ed149a7c7..2148fe7eb 100644 --- a/packages/coding-agent/src/tools/memory-reflect.ts +++ b/packages/coding-agent/src/tools/memory-reflect.ts @@ -1,6 +1,6 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { logger, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { ensureBankExists } from "../hindsight/bank"; import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" }; import type { ToolSession } from "."; diff --git a/packages/coding-agent/src/tools/memory-retain.ts b/packages/coding-agent/src/tools/memory-retain.ts index 98b4906a9..faa0c56e0 100644 --- a/packages/coding-agent/src/tools/memory-retain.ts +++ b/packages/coding-agent/src/tools/memory-retain.ts @@ -1,5 +1,5 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; -import { type } from "arktype"; import retainDescription from "../prompts/tools/retain.md" with { type: "text" }; import type { ToolSession } from "."; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 95cd116a7..c2d98dbff 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, @@ -25,7 +26,6 @@ import { readImageMetadata, untilAborted, } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { LRUCache } from "lru-cache/raw"; import { canonicalSnapshotKey, diff --git a/packages/coding-agent/src/tools/security-scan.ts b/packages/coding-agent/src/tools/security-scan.ts index 9adb38210..2f51f8ca3 100644 --- a/packages/coding-agent/src/tools/security-scan.ts +++ b/packages/coding-agent/src/tools/security-scan.ts @@ -1,5 +1,5 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult, ToolTier } from "@oh-my-pi/pi-agent-core"; -import { type } from "arktype"; import securityScanDescription from "../prompts/tools/security-scan.md" with { type: "text" }; import { selectSecurityAccount } from "../security/auth"; import { diff --git a/packages/coding-agent/src/tools/todo.ts b/packages/coding-agent/src/tools/todo.ts index 30946cf4e..87f5a7a2c 100644 --- a/packages/coding-agent/src/tools/todo.ts +++ b/packages/coding-agent/src/tools/todo.ts @@ -1,9 +1,9 @@ +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ToolExample } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { isRecord, prompt, sanitizeText } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import chalk from "chalk"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/tools/tts.ts b/packages/coding-agent/src/tools/tts.ts index 74e19240a..caf52bd08 100644 --- a/packages/coding-agent/src/tools/tts.ts +++ b/packages/coding-agent/src/tools/tts.ts @@ -3,10 +3,10 @@ // backend (Kokoro-82M via kokoro-js on the shared ONNX worker) is layered on behind // the `providers.tts` switch. +import { type } from "@oh-my-pi/omptype"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { type ApiKey, withAuth } from "@oh-my-pi/pi-ai"; import { ProviderHttpError } from "@oh-my-pi/pi-ai/error"; -import { type } from "arktype"; import { settings } from "../config/settings"; import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types"; import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http"; diff --git a/packages/coding-agent/src/tools/vibe.ts b/packages/coding-agent/src/tools/vibe.ts index edc513724..0f7965239 100644 --- a/packages/coding-agent/src/tools/vibe.ts +++ b/packages/coding-agent/src/tools/vibe.ts @@ -10,11 +10,12 @@ * terminal), and wait/list draw the "TV wall" — one live screen per worker, * stacked, each showing its tool calls and streamed text as it works. */ + +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { shimmerEnabled, shimmerText } from "../modes/theme/shimmer"; import type { Theme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index f3c53b880..af6aca18b 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { formatHashlineHeader, stripHashlinePrefixes } from "@oh-my-pi/hashline"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, @@ -12,7 +13,6 @@ import type { } from "@oh-my-pi/pi-agent-core"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store"; import { normalizeToLF } from "../edit/normalize"; diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 90d6a3850..c0c297a48 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -4,10 +4,11 @@ * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, SearXNG, and Synthetic * providers with provider-specific parameters exposed conditionally. */ + +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { AuthStorage } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { ModelRegistry } from "../../config/model-registry"; import { settings } from "../../config/settings"; import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types"; diff --git a/packages/coding-agent/test/acp-event-mapper.test.ts b/packages/coding-agent/test/acp-event-mapper.test.ts index 7432837e1..f32bb80a5 100644 --- a/packages/coding-agent/test/acp-event-mapper.test.ts +++ b/packages/coding-agent/test/acp-event-mapper.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import path from "node:path"; import type { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; -import { type } from "arktype"; +import { type } from "@oh-my-pi/omptype"; const arkSessionNotification = type({ sessionId: "string", diff --git a/packages/coding-agent/test/acp-initialize-conformance.test.ts b/packages/coding-agent/test/acp-initialize-conformance.test.ts index b25e82d83..971615319 100644 --- a/packages/coding-agent/test/acp-initialize-conformance.test.ts +++ b/packages/coding-agent/test/acp-initialize-conformance.test.ts @@ -8,6 +8,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentSideConnection, InitializeRequest } from "@agentclientprotocol/sdk"; +import { type } from "@oh-my-pi/omptype"; import type { Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { AcpAgent } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-agent"; @@ -15,7 +16,6 @@ import { ACP_TERMINAL_AUTH_FLAG, prepareAcpTerminalAuthArgs } from "@oh-my-pi/pi import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getConfigRootDir, setAgentDir, VERSION } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { expectAcpStructure } from "./helpers/acp-schema"; const arkInitializeResponse = type({ diff --git a/packages/coding-agent/test/advisor/advisor.test.ts b/packages/coding-agent/test/advisor/advisor.test.ts index b2832c14d..2f70d1d7f 100644 --- a/packages/coding-agent/test/advisor/advisor.test.ts +++ b/packages/coding-agent/test/advisor/advisor.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import type { AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols"; import type { TUI } from "@oh-my-pi/pi-tui"; -import { type } from "arktype"; import { ADVISOR_DEFAULT_TOOL_NAMES, AdviseTool, diff --git a/packages/coding-agent/test/agent-session-acp-permission.test.ts b/packages/coding-agent/test/agent-session-acp-permission.test.ts index b02f7141c..09b3dd1d2 100644 --- a/packages/coding-agent/test/agent-session-acp-permission.test.ts +++ b/packages/coding-agent/test/agent-session-acp-permission.test.ts @@ -6,6 +6,7 @@ * behavior they have in the TUI. */ import { afterEach, beforeEach, expect, it, spyOn } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModelOptions } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; @@ -23,7 +24,6 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { dispatchXdevTool, resolveMountedXdevExecutable, type XdevState } from "@oh-my-pi/pi-coding-agent/tools/xdev"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; // --------------------------------------------------------------------------- // Shared setup diff --git a/packages/coding-agent/test/agent-session-advisor-suppression.test.ts b/packages/coding-agent/test/agent-session-advisor-suppression.test.ts index 1de6e489f..0142ed2a7 100644 --- a/packages/coding-agent/test/agent-session-advisor-suppression.test.ts +++ b/packages/coding-agent/test/agent-session-advisor-suppression.test.ts @@ -18,6 +18,7 @@ * follow-up stays queued for the next explicit resume rather than auto-running. */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { ToolCall } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; @@ -30,7 +31,6 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { Snowflake, TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; interface MockYieldDetails { status: "success"; diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 783c399f8..aa272e938 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -7,6 +7,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; +import { type } from "@oh-my-pi/omptype"; import { Agent, AgentBusyError, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Message, ToolCall } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; @@ -26,7 +27,6 @@ import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { createAssistantMessage } from "./helpers/agent-session-setup"; // Mock stream that mimics AssistantMessageEventStream diff --git a/packages/coding-agent/test/agent-session-eager-compaction.test.ts b/packages/coding-agent/test/agent-session-eager-compaction.test.ts index 32200844a..058926fe1 100644 --- a/packages/coding-agent/test/agent-session-eager-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-eager-compaction.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import type { Model, TextContent } from "@oh-my-pi/pi-ai"; @@ -14,7 +15,6 @@ import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TodoTool, type ToolSession, USER_TODO_EDIT_CUSTOM_TYPE } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; // Re-injecting eager preludes after compaction: the first-message preludes are the // oldest messages, so compaction summarizes them away and the agent silently loses diff --git a/packages/coding-agent/test/agent-session-eager-task.test.ts b/packages/coding-agent/test/agent-session-eager-task.test.ts index 27cf73054..e3b5c3e73 100644 --- a/packages/coding-agent/test/agent-session-eager-task.test.ts +++ b/packages/coding-agent/test/agent-session-eager-task.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { TextContent } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; @@ -12,7 +13,6 @@ import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TodoTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { createAssistantMessage } from "./helpers/agent-session-setup"; type ObservedPromptCall = { diff --git a/packages/coding-agent/test/agent-session-eager-todo.test.ts b/packages/coding-agent/test/agent-session-eager-todo.test.ts index 0d0bf607b..5a249e407 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, TextContent, ToolCall } from "@oh-my-pi/pi-ai"; import * as ai from "@oh-my-pi/pi-ai"; @@ -14,7 +15,6 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TodoTool } from "@oh-my-pi/pi-coding-agent/tools"; import { setInteractiveHost, TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { createAssistantMessage } from "./helpers/agent-session-setup"; type ObservedPromptCall = { diff --git a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts index 1f55264c8..d9dd6814d 100644 --- a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts +++ b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, it, vi } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -10,7 +11,6 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; let tempDir: TempDir; let authStorage: AuthStorage | undefined; diff --git a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts index 7a30df5aa..3fb9540b6 100644 --- a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; @@ -15,7 +16,6 @@ import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; function activeGoalState(): GoalModeState { const now = Date.now(); diff --git a/packages/coding-agent/test/agent-session-magic-keywords.test.ts b/packages/coding-agent/test/agent-session-magic-keywords.test.ts index e1b8a50a7..86e62affc 100644 --- a/packages/coding-agent/test/agent-session-magic-keywords.test.ts +++ b/packages/coding-agent/test/agent-session-magic-keywords.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { Effort } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -13,7 +14,6 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; const mockTaskTool: AgentTool = { name: "task", diff --git a/packages/coding-agent/test/agent-session-memory-backend.test.ts b/packages/coding-agent/test/agent-session-memory-backend.test.ts index 9f2050a18..d6c6ba655 100644 --- a/packages/coding-agent/test/agent-session-memory-backend.test.ts +++ b/packages/coding-agent/test/agent-session-memory-backend.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; @@ -11,7 +12,6 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; function createTool(name: string): AgentTool { return { diff --git a/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts b/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts index 2d492cfa0..c37511d47 100644 --- a/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts +++ b/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts @@ -11,6 +11,7 @@ * user), and either decision tool resets the counter. */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool, type StreamFn } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -23,7 +24,6 @@ import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { Snowflake, TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import planModeReminderPrompt from "../src/prompts/system/plan-mode-tool-decision-reminder.md" with { type: "text" }; /** A stable, literal (non-templated) line of the reminder prompt, so the test diff --git a/packages/coding-agent/test/agent-session-skill-keywords.test.ts b/packages/coding-agent/test/agent-session-skill-keywords.test.ts index d7ca5db34..7badbede5 100644 --- a/packages/coding-agent/test/agent-session-skill-keywords.test.ts +++ b/packages/coding-agent/test/agent-session-skill-keywords.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -15,7 +16,6 @@ import { } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { createAssistantMessage } from "./helpers/agent-session-setup"; type ObservedSkillTurn = { diff --git a/packages/coding-agent/test/agent-session-tool-call-loop-guard.test.ts b/packages/coding-agent/test/agent-session-tool-call-loop-guard.test.ts index 42b57578f..cc5e06649 100644 --- a/packages/coding-agent/test/agent-session-tool-call-loop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-tool-call-loop-guard.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Context } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; @@ -11,7 +12,6 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { type CustomMessage, convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; const zeroUsage = { input: 0, diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 7accdbff0..9613c0446 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Message, Model } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockResponseSource } from "@oh-my-pi/pi-ai/providers/mock"; @@ -15,7 +16,6 @@ import { } from "@oh-my-pi/pi-coding-agent/session/session-tools"; import { listXdevTools, XDEV_EXTERNAL_DESCRIPTION_CAP, type XdevState } from "@oh-my-pi/pi-coding-agent/tools/xdev"; import { logger } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; // Cache-stability invariant: when MCP servers reconnect with byte-identical tool // definitions, `refreshMCPTools` must not rebuild the system prompt. A rebuild diff --git a/packages/coding-agent/test/autolearn-controller.test.ts b/packages/coding-agent/test/autolearn-controller.test.ts index 3d9966052..60face8b6 100644 --- a/packages/coding-agent/test/autolearn-controller.test.ts +++ b/packages/coding-agent/test/autolearn-controller.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentOptions, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, FetchImpl, Model, ProviderSessionState, Usage } from "@oh-my-pi/pi-ai"; import { streamGoogle } from "@oh-my-pi/pi-ai/providers/google"; @@ -9,7 +10,6 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAutoLearnCaptureRunner } from "@oh-my-pi/pi-coding-agent/sdk"; import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; -import { type } from "arktype"; class FakeSession { readonly listeners: Array<(event: AgentSessionEvent) => void> = []; diff --git a/packages/coding-agent/test/autolearn-tools-gating.test.ts b/packages/coding-agent/test/autolearn-tools-gating.test.ts index 9e477ec07..223db8e89 100644 --- a/packages/coding-agent/test/autolearn-tools-gating.test.ts +++ b/packages/coding-agent/test/autolearn-tools-gating.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { getManagedSkillsDir } from "@oh-my-pi/pi-coding-agent/autolearn/managed-skills"; import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resetActiveSkillsForTests, type Skill, setActiveSkills } from "@oh-my-pi/pi-coding-agent/extensibility/skills"; @@ -12,7 +13,6 @@ import { LearnTool } from "@oh-my-pi/pi-coding-agent/tools/learn"; import { ManageSkillTool } from "@oh-my-pi/pi-coding-agent/tools/manage-skill"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; import { getAgentDir, setAgentDir } from "@oh-my-pi/pi-utils/dirs"; -import { type } from "arktype"; function makeSession( settingsOverrides: Partial> = {}, diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 19a5ea43f..ef19afb46 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -7,6 +7,7 @@ import { formatHashlineHeader, MismatchError as HashlineMismatchError, } from "@oh-my-pi/hashline"; +import { type Type, type } from "@oh-my-pi/omptype"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { canonicalSnapshotKey, @@ -19,7 +20,6 @@ import { import { resolveLocalUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; -import { type Type, type } from "arktype"; beforeAll(async () => { resetSettingsForTest(); diff --git a/packages/coding-agent/test/core/js-executor.test.ts b/packages/coding-agent/test/core/js-executor.test.ts index f0886a915..637923af6 100644 --- a/packages/coding-agent/test/core/js-executor.test.ts +++ b/packages/coding-agent/test/core/js-executor.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, setDefaultTimeout, vi } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context-manager"; @@ -7,7 +8,6 @@ import { executeJs, type JsResult } from "@oh-my-pi/pi-coding-agent/eval/js/exec import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; -import { type } from "arktype"; // JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup // can exceed Bun's 5s default per-test timeout, flaking the suite. Give the worker-backed diff --git a/packages/coding-agent/test/core/js-tool-bridge.test.ts b/packages/coding-agent/test/core/js-tool-bridge.test.ts index fb1f3809b..eafb4c768 100644 --- a/packages/coding-agent/test/core/js-tool-bridge.test.ts +++ b/packages/coding-agent/test/core/js-tool-bridge.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { callSessionTool } from "@oh-my-pi/pi-coding-agent/eval/js/tool-bridge"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; -import { type } from "arktype"; function createTool( name: string, diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index 202dc0ac1..601d72cc8 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { create, fromBinary } from "@bufbuild/protobuf"; +import { type } from "@oh-my-pi/omptype"; import type { AgentEvent, AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { type BlockState, handleServerMessage, type ToolCallState } from "@oh-my-pi/pi-ai/providers/cursor"; import { buildPiLsResult, piTruncation } from "@oh-my-pi/pi-ai/providers/cursor/exec-modern"; @@ -31,7 +32,6 @@ import { BUILTIN_TOOLS, GrepTool, ReadTool, type Tool, type ToolSession } from " import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; import type { TruncationMeta } from "@oh-my-pi/pi-coding-agent/tools/output-meta"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { AdviseTool } from "../src/advisor/advise-tool"; function createTestSession(cwd: string, overrides: Partial = {}): ToolSession { diff --git a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts index cea7599e3..fa4302c75 100644 --- a/packages/coding-agent/test/edit-auto-generated-regressions.test.ts +++ b/packages/coding-agent/test/edit-auto-generated-regressions.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, StopReason, ToolCall } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; @@ -30,7 +31,6 @@ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import * as autoGeneratedGuard from "@oh-my-pi/pi-coding-agent/tools/auto-generated-guard"; import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; function createAssistantMessage(content: AssistantMessage["content"], stopReason: StopReason): AssistantMessage { return { diff --git a/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts b/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts index 0a96cbf55..b79044fdb 100644 --- a/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts +++ b/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as arktype from "@oh-my-pi/omptype"; +import type * as TypeBox from "@oh-my-pi/omptype/typebox"; +import * as zod from "@oh-my-pi/omptype/zod"; import * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; import { GreenCommand } from "@oh-my-pi/pi-coding-agent/extensibility/custom-commands/bundled/ci-green"; import type { CustomCommandAPI } from "@oh-my-pi/pi-coding-agent/extensibility/custom-commands/types"; import type { HookCommandContext } from "@oh-my-pi/pi-coding-agent/extensibility/hooks/types"; -import type * as TypeBox from "@oh-my-pi/pi-coding-agent/extensibility/typebox"; import * as git from "@oh-my-pi/pi-coding-agent/utils/git"; -import * as arktype from "arktype"; -import * as zod from "zod/v4"; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/coding-agent/test/extensibility/custom-tool-loader.test.ts b/packages/coding-agent/test/extensibility/custom-tool-loader.test.ts index dadf8d021..e7207d137 100644 --- a/packages/coding-agent/test/extensibility/custom-tool-loader.test.ts +++ b/packages/coding-agent/test/extensibility/custom-tool-loader.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; import { loadCustomTools, type ToolPathWithSource } from "../../src/extensibility/custom-tools/loader"; let tempRoot: string | undefined; @@ -71,6 +73,48 @@ const MISSING_NAME_SOURCE = [ ].join("\n"); describe("custom tool loader", () => { + it("injects callable omptype-backed zod schemas through validation and wire emission", async () => { + const toolPath = await writeTool( + "zod-wire.js", + [ + "export default api => ({", + '\tname: "zod_wire_tool",', + '\tdescription: "Validates an injected zod schema",', + "\tparameters: api.zod.object({ path: api.zod.string() }),", + "\tasync execute() {", + '\t\treturn { content: [{ type: "text", text: "ok" }] };', + "\t},", + "});", + ].join("\n"), + ); + const result = await loadCustomTools([{ path: toolPath }], requireTempRoot(), []); + const tool = result.tools[0]?.tool; + + expect(result.errors).toEqual([]); + expect(tool).toBeDefined(); + expect( + validateToolArguments(tool!, { + type: "toolCall", + id: "valid", + name: tool!.name, + arguments: { path: "README.md" }, + }), + ).toEqual({ path: "README.md" }); + expect(() => + validateToolArguments(tool!, { + type: "toolCall", + id: "invalid", + name: tool!.name, + arguments: {}, + }), + ).toThrow('Validation failed for tool "zod_wire_tool"'); + expect(toolWireSchema(tool!)).toMatchObject({ + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }); + }); + it("skips a tool that calls process.exit synchronously at import time and still loads later valid tools", async () => { // CLI-shaped module: main() at the bottom, exit on failure (issue #1704). // Without the exit guard this terminates the test process before the diff --git a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts index dfc8fa984..59dcb3c53 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts @@ -10,12 +10,12 @@ import { getBundledProviders, modelsAreEqual, } from "@oh-my-pi/pi-catalog/models"; +import { Type as TypeBoxShimType } from "@oh-my-pi/pi-coding-agent/extensibility/legacy-typebox"; import { __resetLegacyPiResolutionCache, installLegacyPiSpecifierShim, loadLegacyPiModule, } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat"; -import { Type as TypeBoxShimType } from "@oh-my-pi/pi-coding-agent/extensibility/typebox"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; // pi-ai 15.1.0 removed the runtime `Type` export from `@oh-my-pi/pi-ai`'s diff --git a/packages/coding-agent/test/extensibility/tool-proxy.test.ts b/packages/coding-agent/test/extensibility/tool-proxy.test.ts new file mode 100644 index 000000000..c77620a51 --- /dev/null +++ b/packages/coding-agent/test/extensibility/tool-proxy.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; +import { isArkSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { applyToolProxy } from "../../src/extensibility/tool-proxy"; + +describe("applyToolProxy", () => { + class DemoTool { + name = "demo"; + description = "demo tool"; + parameters = type({ a: "string" }); + async execute(): Promise { + return this.name; + } + } + + it("preserves schema callables: parameters stay wire-detectable through the wrapper", () => { + // Regression: omptype schemas are plain functions carrying toJsonSchema/ + // assert as own properties; binding them stripped those properties, so + // isArkSchema failed and JSON.stringify(schema) yielded undefined + // downstream (status-line tokenizer crash). + const wrapper: Record = {}; + applyToolProxy(new DemoTool(), wrapper); + expect(isArkSchema(wrapper.parameters)).toBe(true); + expect(wrapper.parameters).toBeInstanceOf(Function); + }); + + it("binds prototype methods to the underlying tool", async () => { + const wrapper: Record = {}; + applyToolProxy(new DemoTool(), wrapper); + const execute = wrapper.execute as () => Promise; + // `this` must resolve to the inner tool even when called off the wrapper + await expect(execute.call({ name: "WRONG" })).resolves.toBe("demo"); + }); + + it("passes own data properties through unchanged", () => { + const inner = new DemoTool(); + const wrapper: Record = {}; + applyToolProxy(inner, wrapper); + expect(wrapper.name).toBe("demo"); + expect(wrapper.parameters).toBe(inner.parameters); + }); +}); diff --git a/packages/coding-agent/test/extensibility/typebox-remap.test.ts b/packages/coding-agent/test/extensibility/typebox-remap.test.ts index 4b62d6047..3963b02ef 100644 --- a/packages/coding-agent/test/extensibility/typebox-remap.test.ts +++ b/packages/coding-agent/test/extensibility/typebox-remap.test.ts @@ -2,18 +2,19 @@ import { afterAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { Type as TypeBoxShimType } from "@oh-my-pi/pi-coding-agent/extensibility/legacy-typebox"; import { installLegacyPiSpecifierShim, loadLegacyPiModule, } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat"; -import { Type as TypeBoxShimType } from "@oh-my-pi/pi-coding-agent/extensibility/typebox"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; // The remap installs a Bun.plugin onResolve hook plus an explicit // rewrite branch inside `rewriteBareImportsForLegacyExtension` that -// redirects bare `@sinclair/typebox` specifiers to the in-repo Zod-backed -// shim. Extensions that authored against TypeBox should keep working -// unchanged without `@sinclair/typebox` ever needing to be installed. +// redirects bare `@sinclair/typebox` specifiers to the omptype-backed +// compatibility surface. Extensions should keep working unchanged without +// `@sinclair/typebox` ever needing to be installed. installLegacyPiSpecifierShim(); const tempRoots: string[] = []; @@ -57,17 +58,17 @@ describe("legacy-pi TypeBox remap", () => { [ 'import { Type } from "typebox";', "export const probe = Type;", - "export const unsafeSchema = Type.Unsafe({ type: 'object', properties: { path: { type: 'string' } }, required: ['path'] });", + "export const schema = Type.Unsafe({ type: 'object', properties: { path: { type: 'string' } }, required: ['path'] });", ].join("\n"), ); const loaded = (await loadLegacyPiModule(entry)) as { probe: typeof TypeBoxShimType; - unsafeSchema: Record; + schema: Record; }; expect(loaded.probe).toBe(TypeBoxShimType); - expect({ ...loaded.unsafeSchema }).toEqual({ + expect(toolWireSchema({ name: "fixture", description: "", parameters: loaded.schema })).toEqual({ type: "object", properties: { path: { type: "string" } }, required: ["path"], diff --git a/packages/coding-agent/test/extensibility/typebox-shim.test.ts b/packages/coding-agent/test/extensibility/typebox-shim.test.ts index 1e1a59c9f..ade6fc715 100644 --- a/packages/coding-agent/test/extensibility/typebox-shim.test.ts +++ b/packages/coding-agent/test/extensibility/typebox-shim.test.ts @@ -2,14 +2,11 @@ import { describe, expect, it } from "bun:test"; import type { Tool } from "@oh-my-pi/pi-ai/types"; import { isValidJsonSchema, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; -import { type TSchema, Type } from "@oh-my-pi/pi-coding-agent/extensibility/typebox"; +import { type TSchema, Type } from "@oh-my-pi/pi-coding-agent/extensibility/legacy-typebox"; /** - * The typebox shim's `Type.*` builders return arktype-backed validator wrappers - * (`TSchema`), not Zod schemas, so they expose neither `.parse` nor `.safeParse`. - * The wrapped validator returns the validated value on success, or an object with - * a `message` property on failure (mirroring the shim's internal `validate`). This - * helper reproduces a `.safeParse`-style result on top of that contract. + * Exercise the legacy `__validator` failure-marker contract independently of + * the facade's `safeParse` helper. */ function safeParse(schema: TSchema, value: unknown): { success: boolean; data?: unknown } { const result = schema.__validator(value); @@ -84,7 +81,7 @@ describe("pi.typebox compatibility shim", () => { it("enforces and emits uniqueItems for arrays", () => { const schema = Type.Array(Type.String(), { uniqueItems: true }); - const wire = toolWireSchema({ name: "files", description: "", parameters: { ...schema } }); + const wire = toolWireSchema({ name: "files", description: "", parameters: schema }); expect(safeParse(schema, ["a.ts", "b.ts"]).success).toBe(true); expect(safeParse(schema, ["a.ts", "a.ts"]).success).toBe(false); @@ -132,19 +129,16 @@ describe("pi.typebox compatibility shim", () => { expect((parsed.data as { extra?: unknown }).extra).toBe(1); } }); - // Regression: issue #1101. Real TypeBox lets extension authors do - // `JSON.stringify(schema)` and get a clean JSON Schema — that's the - // contract the shim is impersonating. Without a `toJSON` stamp, the shim - // leaks raw Zod internals (`def`, `_zod`, object-shaped `enum`, - // `"type":"enum"`) and breaks any pipeline that crosses a JSON boundary. - describe("JSON.stringify produces valid JSON Schema (TypeBox contract)", () => { + // Callable omptype schemas intentionally have no enumerable JSON Schema + // keywords. Wire serialization consumes their explicit JSON Schema emitter. + describe("toJsonSchema produces valid wire JSON Schema", () => { it("emits clean JSON Schema for a complex object", () => { const schema = Type.Object({ direction: Type.Enum({ upstream: "upstream", downstream: "downstream" }), depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 3 })), tags: Type.Array(Type.String()), }); - const round = JSON.parse(JSON.stringify(schema)) as Record; + const round = schema.toJsonSchema(); expect(isValidJsonSchema(round)).toBe(true); // No raw Zod internals leak through. expect(round).not.toHaveProperty("_zod"); @@ -161,7 +155,7 @@ describe("pi.typebox compatibility shim", () => { Type.Omit(base, ["a"]), Type.Composite([base, Type.Object({ c: Type.Boolean() })]), ]) { - const round = JSON.parse(JSON.stringify(schema)) as Record; + const round = schema.toJsonSchema(); expect(isValidJsonSchema(round)).toBe(true); expect(round).not.toHaveProperty("_zod"); } diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index a078575d9..8e9cc3358 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -5,6 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, expectTypeOf, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; +import { Type } from "@oh-my-pi/omptype/typebox"; import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -21,7 +22,6 @@ import type { ExtensionUIContext, } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; import { ExtensionToolWrapper } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/wrapper"; -import { Type } from "@oh-my-pi/pi-coding-agent/extensibility/typebox"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getProjectAgentDir, logger, TempDir } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/fixtures/computer-schema-construction-preload.ts b/packages/coding-agent/test/fixtures/computer-schema-construction-preload.ts index 7c292ca44..67ed38a89 100644 --- a/packages/coding-agent/test/fixtures/computer-schema-construction-preload.ts +++ b/packages/coding-agent/test/fixtures/computer-schema-construction-preload.ts @@ -1,5 +1,5 @@ import { spyOn } from "bun:test"; -import * as arktype from "arktype"; +import * as arktype from "@oh-my-pi/omptype"; declare global { var __computerSchemaConstructionCount: number; @@ -7,8 +7,9 @@ declare global { globalThis.__computerSchemaConstructionCount = 0; const originalType = arktype.type; +const invokeType = originalType as unknown as (definition: unknown) => unknown; const countedType = new Proxy(originalType, { - apply(target, thisArg, args) { + apply(_target, thisArg, args) { const definition = args[0]; if ( definition !== null && @@ -18,7 +19,7 @@ const countedType = new Proxy(originalType, { ) { globalThis.__computerSchemaConstructionCount += 1; } - return Reflect.apply(target, thisArg, args); + return Reflect.apply(invokeType, thisArg, args); }, }); const typeSpy = spyOn(arktype, "type").mockImplementation(countedType); diff --git a/packages/coding-agent/test/fixtures/computer-schema-construction-probe.ts b/packages/coding-agent/test/fixtures/computer-schema-construction-probe.ts index c4f8928b8..5bae392e8 100644 --- a/packages/coding-agent/test/fixtures/computer-schema-construction-probe.ts +++ b/packages/coding-agent/test/fixtures/computer-schema-construction-probe.ts @@ -1,6 +1,6 @@ +import { type as arkType } from "@oh-my-pi/omptype"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { BUILTIN_TOOLS, ComputerTool, createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { type as arkType } from "arktype"; declare global { var __computerSchemaConstructionCount: number; diff --git a/packages/coding-agent/test/helpers/acp-schema.ts b/packages/coding-agent/test/helpers/acp-schema.ts index 6158c26af..f96df38fd 100644 --- a/packages/coding-agent/test/helpers/acp-schema.ts +++ b/packages/coding-agent/test/helpers/acp-schema.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test"; -import { type as arkType, type Type } from "arktype"; +import { type as arkType, type Type } from "@oh-my-pi/omptype"; function formatIssues(error: unknown): string { if (error instanceof Error) { diff --git a/packages/coding-agent/test/hook-tool-wrapper-input.test.ts b/packages/coding-agent/test/hook-tool-wrapper-input.test.ts index cf4ea4fb1..f47c10171 100644 --- a/packages/coding-agent/test/hook-tool-wrapper-input.test.ts +++ b/packages/coding-agent/test/hook-tool-wrapper-input.test.ts @@ -5,11 +5,11 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import * as path from "node:path"; +import { Type } from "@oh-my-pi/omptype/typebox"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { HookRunner, type LoadedHook } from "@oh-my-pi/pi-coding-agent/extensibility/hooks"; import { HookToolWrapper } from "@oh-my-pi/pi-coding-agent/extensibility/hooks/tool-wrapper"; -import { Type } from "@oh-my-pi/pi-coding-agent/extensibility/typebox"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts index 7289b6956..9e24423e5 100644 --- a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts +++ b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -8,7 +9,6 @@ import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { ModelRegistry } from "../src/config/model-registry"; import type { CustomTool } from "../src/extensibility/custom-tools/types"; import { InteractiveMode } from "../src/modes/interactive-mode"; diff --git a/packages/coding-agent/test/interactive-mode-prompt-template-autocomplete.test.ts b/packages/coding-agent/test/interactive-mode-prompt-template-autocomplete.test.ts index 48ae1a06b..be6166e25 100644 --- a/packages/coding-agent/test/interactive-mode-prompt-template-autocomplete.test.ts +++ b/packages/coding-agent/test/interactive-mode-prompt-template-autocomplete.test.ts @@ -8,6 +8,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -20,7 +21,6 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { AutocompleteProvider } from "@oh-my-pi/pi-tui"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; function makeTool(name: string): AgentTool { return { diff --git a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts index 40f17f270..99439c0dc 100644 --- a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts +++ b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts @@ -9,6 +9,7 @@ */ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -23,7 +24,6 @@ import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; function stubTool(name: string): AgentTool { return { diff --git a/packages/coding-agent/test/issue-4919-extension-autocomplete-provider.test.ts b/packages/coding-agent/test/issue-4919-extension-autocomplete-provider.test.ts index 31fb9c42d..56e7098fc 100644 --- a/packages/coding-agent/test/issue-4919-extension-autocomplete-provider.test.ts +++ b/packages/coding-agent/test/issue-4919-extension-autocomplete-provider.test.ts @@ -17,11 +17,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai"; import type { AutocompleteProvider } from "@oh-my-pi/pi-tui"; import { logger, TempDir } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; import { ModelRegistry } from "../src/config/model-registry"; import { resetSettingsForTest, Settings } from "../src/config/settings"; import { loadExtensions } from "../src/extensibility/extensions/loader"; diff --git a/packages/coding-agent/test/issue-5764-registertool-loadmode.test.ts b/packages/coding-agent/test/issue-5764-registertool-loadmode.test.ts index a212073cc..59360fc2b 100644 --- a/packages/coding-agent/test/issue-5764-registertool-loadmode.test.ts +++ b/packages/coding-agent/test/issue-5764-registertool-loadmode.test.ts @@ -6,6 +6,7 @@ * / `write xd://`). */ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { CustomToolAdapter } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/wrapper"; import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; @@ -16,7 +17,6 @@ import { ESSENTIAL_BUILTIN_TOOL_NAMES, } from "@oh-my-pi/pi-coding-agent/tools/essential-tools"; import { isMountableUnderXdev } from "@oh-my-pi/pi-coding-agent/tools/xdev"; -import { type } from "arktype"; function makeSession(): ToolSession { return { diff --git a/packages/coding-agent/test/modes/context-usage.test.ts b/packages/coding-agent/test/modes/context-usage.test.ts index 16807e69b..0571a5371 100644 --- a/packages/coding-agent/test/modes/context-usage.test.ts +++ b/packages/coding-agent/test/modes/context-usage.test.ts @@ -6,6 +6,7 @@ * internals, which massively overcounts. */ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { arkToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { type ContextBreakdown, @@ -14,7 +15,6 @@ import { estimateToolSchemaTokens, renderContextUsage, } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage"; -import { type } from "arktype"; describe("estimateToolSchemaTokens", () => { it("counts arktype tool schemas by their wire JSON Schema, not arktype internals", () => { diff --git a/packages/coding-agent/test/sdk-async-job-manager-singleton.test.ts b/packages/coding-agent/test/sdk-async-job-manager-singleton.test.ts index 9bc0c2517..10fc55754 100644 --- a/packages/coding-agent/test/sdk-async-job-manager-singleton.test.ts +++ b/packages/coding-agent/test/sdk-async-job-manager-singleton.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -9,7 +10,6 @@ import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-a import type { AsyncJobSnapshot } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; describe("AsyncJobManager singleton across concurrent top-level sessions", () => { const tempDirs: string[] = []; diff --git a/packages/coding-agent/test/sdk-generate-image-tool-gating.test.ts b/packages/coding-agent/test/sdk-generate-image-tool-gating.test.ts index b35d6fda9..78c97cc78 100644 --- a/packages/coding-agent/test/sdk-generate-image-tool-gating.test.ts +++ b/packages/coding-agent/test/sdk-generate-image-tool-gating.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { AuthStorage } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -12,7 +13,6 @@ import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; // Regression for issue #5305: image-gen is registered as a custom tool, and // custom tools are force-activated regardless of the `toolNames` filter. Before diff --git a/packages/coding-agent/test/sdk-tool-activation.test.ts b/packages/coding-agent/test/sdk-tool-activation.test.ts index 174c64dd4..bfcc3700f 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:te import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { StreamFn } from "@oh-my-pi/pi-agent-core"; import type { Model, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; @@ -21,7 +22,6 @@ import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-sessi import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe"; import { logger, removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; const toolActivationExtension: ExtensionFactory = pi => { pi.registerTool({ diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 3502419e5..3b1927daf 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -7,6 +7,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Context, Message, TextContent } from "@oh-my-pi/pi-ai"; import { @@ -31,7 +32,6 @@ import { } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator"; import { compileSecretRegex } from "@oh-my-pi/pi-coding-agent/secrets/regex"; import { getActiveProfile, getAgentDir, setProfile } from "@oh-my-pi/pi-utils/dirs"; -import { type } from "arktype"; describe("compileSecretRegex", () => { it("adds global flag when not provided", () => { diff --git a/packages/coding-agent/test/session/session-dump-format.test.ts b/packages/coding-agent/test/session/session-dump-format.test.ts index 45de9e0aa..dd691c1ba 100644 --- a/packages/coding-agent/test/session/session-dump-format.test.ts +++ b/packages/coding-agent/test/session/session-dump-format.test.ts @@ -8,10 +8,10 @@ * previous ``-per-key JSON Schema dump dropped entirely. */ import { describe, expect, it } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import type { Model, Usage } from "@oh-my-pi/pi-ai"; import { formatSessionDumpText } from "@oh-my-pi/pi-coding-agent/session/session-dump-format"; import { INTENT_FIELD } from "@oh-my-pi/pi-wire"; -import { type } from "arktype"; const ZERO_USAGE: Usage = { input: 0, diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index f2862e7e0..1fd4ef527 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, StopReason, ToolCall } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; @@ -18,7 +19,6 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage import * as autoGeneratedGuard from "@oh-my-pi/pi-coding-agent/tools/auto-generated-guard"; import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; function createAssistantMessage(content: AssistantMessage["content"], stopReason: StopReason): AssistantMessage { return { diff --git a/packages/coding-agent/test/task/task-schema.test.ts b/packages/coding-agent/test/task/task-schema.test.ts index fed78adb4..b9ddd7347 100644 --- a/packages/coding-agent/test/task/task-schema.test.ts +++ b/packages/coding-agent/test/task/task-schema.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { TaskTool, taskSchema } from "@oh-my-pi/pi-coding-agent/task"; import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { type } from "arktype"; // Contract: the single-spawn schema (`task.batch: false`; the exported // `taskSchema` instance) carries no batch fields while accepting a caller diff --git a/packages/coding-agent/test/task/wire-schema.test.ts b/packages/coding-agent/test/task/wire-schema.test.ts index 176abbd05..8f81f6861 100644 --- a/packages/coding-agent/test/task/wire-schema.test.ts +++ b/packages/coding-agent/test/task/wire-schema.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import { type } from "@oh-my-pi/omptype"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { TaskTool, taskSchema } from "@oh-my-pi/pi-coding-agent/task"; import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery"; import { getTaskSchema, oneLineLabel } from "@oh-my-pi/pi-coding-agent/task/types"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { type } from "arktype"; // Contract: the task tool's wire shape is flat `{ name?, agent?, task, isolated? }` // (batch: `{ context, tasks[] }` of the same items). `agent` defaults to the diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index 0008265f9..6a5688d26 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it, vi } from "bun:test"; import { stripVTControlCharacters } from "node:util"; +import { type } from "@oh-my-pi/omptype"; import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { @@ -11,7 +12,6 @@ import { getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { AskTool, askToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ask"; import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; -import { type } from "arktype"; function createSession(overrides: Partial = {}): ToolSession { return { diff --git a/packages/coding-agent/test/tools/computer.test.ts b/packages/coding-agent/test/tools/computer.test.ts index 325545dde..f8df3ae8b 100644 --- a/packages/coding-agent/test/tools/computer.test.ts +++ b/packages/coding-agent/test/tools/computer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { type as arkType } from "@oh-my-pi/omptype"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ComputerTool, computerApproval, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import type { @@ -19,7 +20,6 @@ import type { DesktopWindow, PointerOptions, } from "@oh-my-pi/pi-natives"; -import { type as arkType } from "arktype"; const capabilities: DesktopCapabilities = { backend: "fake", diff --git a/packages/coding-agent/test/tools/inspect-image.test.ts b/packages/coding-agent/test/tools/inspect-image.test.ts index 0efd7c3b2..93e19001d 100644 --- a/packages/coding-agent/test/tools/inspect-image.test.ts +++ b/packages/coding-agent/test/tools/inspect-image.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { AuthStorage, type completeSimple, Effort, type ImageContent, type Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -14,7 +15,6 @@ import { InspectImageTool } from "@oh-my-pi/pi-coding-agent/tools/inspect-image" import { inspectImageToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/inspect-image-renderer"; import { toolRenderers } from "@oh-my-pi/pi-coding-agent/tools/renderers"; import { removeSyncWithRetries, sanitizeText } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; const TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; diff --git a/packages/coding-agent/test/tools/todo.test.ts b/packages/coding-agent/test/tools/todo.test.ts index 461747386..964f69972 100644 --- a/packages/coding-agent/test/tools/todo.test.ts +++ b/packages/coding-agent/test/tools/todo.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it } from "bun:test"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; @@ -17,7 +18,6 @@ import { todoToolRenderer, } from "@oh-my-pi/pi-coding-agent/tools"; import type { Component } from "@oh-my-pi/pi-tui"; -import { type } from "arktype"; function createSession(initialPhases: TodoPhase[] = []): ToolSession { let phases = initialPhases; diff --git a/packages/coding-agent/test/write-xdev-dispatch.test.ts b/packages/coding-agent/test/write-xdev-dispatch.test.ts index 1343d86b2..6b306b09c 100644 --- a/packages/coding-agent/test/write-xdev-dispatch.test.ts +++ b/packages/coding-agent/test/write-xdev-dispatch.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { type } from "@oh-my-pi/omptype"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -22,7 +23,6 @@ import { xdevEntries, } from "@oh-my-pi/pi-coding-agent/tools/xdev"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; -import { type } from "arktype"; // xdev mounting is default-on: discoverable tools like ast_edit unmount into // xd://, and a plain `write xd://ast_edit` dispatches them. These guard the diff --git a/packages/mnemopi/bench/native-vectors.bench.ts b/packages/mnemopi/bench/native-vectors.bench.ts index b721989be..bf6816d75 100644 --- a/packages/mnemopi/bench/native-vectors.bench.ts +++ b/packages/mnemopi/bench/native-vectors.bench.ts @@ -6,9 +6,7 @@ * Run from the repo root: `bun packages/mnemopi/bench/native-vectors.bench.ts` */ import * as os from "node:os"; -import { - cosineSimilarityPairs, -} from "@oh-my-pi/pi-natives"; +import { cosineSimilarityPairs } from "@oh-my-pi/pi-natives"; import { jaccardSimilarity, mmrRerank } from "../src/core/mmr"; import { searchExactVectorIndex } from "../src/core/vector-index"; import { cosineSimilarity } from "../src/core/vector-math"; @@ -46,7 +44,12 @@ interface Row { nativeIterations: number; } -function pushRow(kernel: string, count: number, ts: { ns: number; iterations: number }, native: { ns: number; iterations: number }): void { +function pushRow( + kernel: string, + count: number, + ts: { ns: number; iterations: number }, + native: { ns: number; iterations: number }, +): void { rows.push({ kernel, count, @@ -124,29 +127,32 @@ for (const count of COUNTS.filter(n => n <= 1000)) { pairIterations, pairWarmup, ); - const native = timeNs(() => { - // Current clusterBySimilarity: flatten, one crossing, then adjacency - // built from the materialized pair list. - let dim = 0; - for (const vector of vectors) if (vector.length > dim) dim = vector.length; - const flat = new Float64Array(vectors.length * dim); - for (let i = 0; i < vectors.length; i += 1) { - const vector = vectors[i]; - if (vector === undefined) continue; - for (let col = 0; col < vector.length; col += 1) flat[i * dim + col] = vector[col] ?? 0; - } - const pairs = cosineSimilarityPairs(flat, vectors.length, dim, threshold); - const adjacency: number[][] = Array.from({ length: count }, () => []); - for (let p = 0; p < pairs.length; p += 2) { - adjacency[pairs[p] ?? 0]?.push(pairs[p + 1] ?? 0); - adjacency[pairs[p + 1] ?? 0]?.push(pairs[p] ?? 0); - } - sink += adjacency[0]?.length ?? 0; - }, pairIterations, pairWarmup); + const native = timeNs( + () => { + // Current clusterBySimilarity: flatten, one crossing, then adjacency + // built from the materialized pair list. + let dim = 0; + for (const vector of vectors) if (vector.length > dim) dim = vector.length; + const flat = new Float64Array(vectors.length * dim); + for (let i = 0; i < vectors.length; i += 1) { + const vector = vectors[i]; + if (vector === undefined) continue; + for (let col = 0; col < vector.length; col += 1) flat[i * dim + col] = vector[col] ?? 0; + } + const pairs = cosineSimilarityPairs(flat, vectors.length, dim, threshold); + const adjacency: number[][] = Array.from({ length: count }, () => []); + for (let p = 0; p < pairs.length; p += 2) { + adjacency[pairs[p] ?? 0]?.push(pairs[p + 1] ?? 0); + adjacency[pairs[p + 1] ?? 0]?.push(pairs[p] ?? 0); + } + sink += adjacency[0]?.length ?? 0; + }, + pairIterations, + pairWarmup, + ); pushRow("cosineSimilarityPairs (incl. flatten+adjacency)", count, ts, native); } - // mmrRerank production paths: the TS side wraps jaccardSimilarity in a lambda, // defeating the identity check so the exact pre-native selection loop runs; the // native side calls mmrRerank with the default similarity, exercising the real diff --git a/packages/natives/bench/grep.ts b/packages/natives/bench/grep.ts index cff6be3ab..224205fc3 100644 --- a/packages/natives/bench/grep.ts +++ b/packages/natives/bench/grep.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { grep, GrepOutputMode } from "../native/index.js"; +import { GrepOutputMode, grep } from "../native/index.js"; const ITERATIONS = Number(Bun.env.GREP_BENCH_ITERATIONS ?? "50"); const CONCURRENCY = 2; @@ -75,7 +75,12 @@ for (const c of cases) { const caseIterations = c.iterations ?? ITERATIONS; const concurrency = c.concurrency ?? CONCURRENCY; const rgDefaultArgs = ["--hidden", "--no-ignore", "--no-ignore-vcs"]; - const modeArg = c.mode === GrepOutputMode.FilesWithMatches ? ["--files-with-matches"] : c.mode === GrepOutputMode.Count ? ["--count"] : ["--json"]; + const modeArg = + c.mode === GrepOutputMode.FilesWithMatches + ? ["--files-with-matches"] + : c.mode === GrepOutputMode.Count + ? ["--count"] + : ["--json"]; const globArg = c.glob ? ["-g", c.glob] : []; const runNative = () => grep(grepArgs); @@ -91,7 +96,7 @@ for (const c of cases) { }; const countMatches = (result: string): number => { - const lines = result.split("\n").filter((line) => line.trim()); + const lines = result.split("\n").filter(line => line.trim()); if (c.mode === GrepOutputMode.FilesWithMatches) { return lines.length; } @@ -114,8 +119,9 @@ for (const c of cases) { return matches; }; - const nativeMetric = (await runNative()); - const nativeMatches = c.mode === GrepOutputMode.FilesWithMatches ? nativeMetric.filesWithMatches : nativeMetric.totalMatches; + const nativeMetric = await runNative(); + const nativeMatches = + c.mode === GrepOutputMode.FilesWithMatches ? nativeMetric.filesWithMatches : nativeMetric.totalMatches; const rgMatches = countMatches(await runRg()); @@ -140,9 +146,13 @@ for (const c of cases) { const rgConcurrentMs = (Bun.nanoseconds() - start) / 1e6 / caseIterations; console.log(`${c.name}:`); - console.log(` Native grep: ${nativeMs.toFixed(2)}ms (${nativeMatches} ${c.mode === GrepOutputMode.FilesWithMatches ? "files" : "matches"})`); + console.log( + ` Native grep: ${nativeMs.toFixed(2)}ms (${nativeMatches} ${c.mode === GrepOutputMode.FilesWithMatches ? "files" : "matches"})`, + ); console.log(` Native grep ${concurrency}x: ${nativeConcurrentMs.toFixed(2)}ms`); - console.log(` Subprocess rg: ${rgMs.toFixed(2)}ms (${rgMatches} ${c.mode === GrepOutputMode.FilesWithMatches ? "files" : "matches"})`); + console.log( + ` Subprocess rg: ${rgMs.toFixed(2)}ms (${rgMatches} ${c.mode === GrepOutputMode.FilesWithMatches ? "files" : "matches"})`, + ); console.log(` Subprocess rg ${concurrency}x: ${rgConcurrentMs.toFixed(2)}ms`); const nativeVsRg = rgMs / nativeMs; diff --git a/packages/omptype/CHANGELOG.md b/packages/omptype/CHANGELOG.md new file mode 100644 index 000000000..09e123a55 --- /dev/null +++ b/packages/omptype/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## [Unreleased] + +### Added + +- Initial release: ArkType-compatible schema validation with a lazy JIT runtime. Schemas interpret their first two calls and compile a specialized validator via `new Function` on the third, making `type()` construction ~100x cheaper than arktype while beating its hot-path validation speed. Supports the string definition DSL (primitives, literals, unions, arrays, bounds, `number.integer`, `string.url`, inline defaults, value-suffix `?` optionals), object definitions (`"+": "reject"/"delete"`, `"[string]"` index signatures), `type.errors`/`OmpErrors` with per-entry `path`/`problem`, `type.enumerated`, `type.raw`, keyword statics, composition methods (`.or/.and/.array/.pipe/.narrow/.describe/.default/.allows/.assert`), static inference via `typeof schema.infer`, and draft-2020-12 `toJsonSchema()` emission. +- TypeBox-style (`@oh-my-pi/omptype/typebox`) and Zod-style (`@oh-my-pi/omptype/zod`) authoring adapters producing native omptype schemas. +- Added recursive named scopes, modules and runtime generics; fixed/optional/variadic tuples; Date literals and bounds; nested string and parse keyword modules; disjointness-aware intersections; structural mapping, selection, distribution and semantic comparison; separate input/output inference; configurable error codes and `byPath`; and JSON Schema target, dialect and fallback handling. +- npm package now ships transpiled ESM in `dist/js` and declarations in `dist/types`, so it runs on plain Node without Bun or a TS loader; Bun consumers keep resolving TS source via the `bun` export condition. + +### Changed + +- Expanded the lazy JIT across tuples, refinements, morphs, intersections, instances, and recursive aliases; reused compiled validators for shared IR and tightened number and literal-union code generation. diff --git a/packages/omptype/README.md b/packages/omptype/README.md new file mode 100644 index 000000000..90b83d23e --- /dev/null +++ b/packages/omptype/README.md @@ -0,0 +1,92 @@ +# @oh-my-pi/omptype + +Fast, ArkType-compatible schema validation for JavaScript and TypeScript. +Schemas start with a small interpreter and lazily compile after repeated use, +keeping construction cheap without giving up hot-path validation speed. + +## Installation + +```sh +npm install @oh-my-pi/omptype +# or +bun add @oh-my-pi/omptype +``` + +Runs on Node 20+ (published as compiled ESM with bundled type declarations) +and Bun 1.3.14+ (which resolves the TypeScript source directly via the `bun` +export condition). No runtime dependencies. + +## Usage + +```ts +import { type } from "@oh-my-pi/omptype"; + +const Config = type({ + name: "string", + "retries?": "number.integer >= 0", + enabled: "boolean = true", +}); + +const config = Config.assert({ name: "worker" }); +// { name: "worker", enabled: true } + +const result = Config({ name: 42 }); +if (result instanceof type.errors) { + console.error(result.summary); +} +``` + +Schemas are callable and expose composition (`.or()`, `.and()`, `.array()`, +`.pipe()`, `.narrow()`), object transforms (`.pick()`, `.omit()`, `.partial()`, +`.required()`, `.merge()`, `.map()`), refinements, semantic comparison, error +configuration, and JSON Schema emission. + +Built-in keyword modules include `type.string.email`, `type.string.uuid.v4`, +`type.string.date.iso.parse`, `type.string.normalize.NFKC`, +`type.number.integer`, and the parsers under `type.parse`. + +## Named and recursive schemas + +```ts +const models = type + .scope({ + User: { name: "string", "manager?": "User" }, + Users: "User[]", + PublicUser: "Pick", + }) + .export(); + +models.User.assert({ name: "Ada", manager: { name: "Grace" } }); +``` + +Scopes resolve aliases lazily, including cycles. `type.module()` exports a +scope directly, `type.define()` preserves literal definitions, and +`type.generic("", definition)` builds parameterized runtime schemas. + +Failed validation returns `OmpErrors`; each entry exposes `code`, `path`, +`expected`, `actual`, `problem`, and `message`, while the aggregate exposes +`summary` and `byPath`. `.configure()` accepts string or callback overrides for +error text. `.toJsonSchema()` accepts `target`, `dialect`, and `fallback` +options. + +## Compatibility adapters + +TypeBox-style and Zod-style builders produce native omptype schemas: + +```ts +import { Type, type Static } from "@oh-my-pi/omptype/typebox"; +import { z } from "@oh-my-pi/omptype/zod"; + +const TypeBoxUser = Type.Object({ name: Type.String() }); +type TypeBoxUser = Static; + +const ZodUser = z.object({ name: z.string() }); +const user = ZodUser.parse({ name: "Ada" }); +``` + +`@oh-my-pi/omptype/ark` provides the repository's ArkType compatibility facade +and re-exports the same `type` and `scope` implementations. + +## License + +MIT diff --git a/packages/omptype/bench/bench.ts b/packages/omptype/bench/bench.ts new file mode 100644 index 000000000..27d1e519e --- /dev/null +++ b/packages/omptype/bench/bench.ts @@ -0,0 +1,190 @@ +/** + * Schema benchmark phases: + * correctness, compile unique schemas, cold compile plus two calls, and hot calls. + */ +import type { Candidate } from "./candidate"; +import { arktypeCandidate } from "./candidates/arktype"; +import { omptypeCandidate } from "./candidates/omptype"; +import { typeboxCandidate } from "./candidates/typebox"; +import { zodCandidate } from "./candidates/zod"; +import { FIXTURES, generateUniqueDefs } from "./fixtures"; +import type { Def } from "./ir"; + +const registry: Candidate[] = [omptypeCandidate, arktypeCandidate, zodCandidate, typeboxCandidate]; +const filter = process.argv.slice(2); +const candidates = filter.length ? registry.filter(candidate => filter.includes(candidate.name)) : registry; + +let correctnessFailed = false; +for (const candidate of candidates) { + let candidateFailed = false; + for (const fixture of FIXTURES) { + let schema: (value: unknown) => unknown; + try { + schema = candidate.type(fixture.def); + } catch (error) { + console.error(`✗ ${candidate.name}/${fixture.name}: type() threw: ${error}`); + candidateFailed = true; + continue; + } + for (const value of fixture.valid) { + const result = schema(structuredClone(value)); + if (candidate.isErrors(result)) { + console.error( + `✗ ${candidate.name}/${fixture.name}: valid input rejected: ${JSON.stringify(value)} → ${candidate.summary?.(result)}`, + ); + candidateFailed = true; + } + } + for (const value of fixture.invalid) { + const result = schema(structuredClone(value)); + if (!candidate.isErrors(result)) { + console.error(`✗ ${candidate.name}/${fixture.name}: invalid input accepted: ${JSON.stringify(value)}`); + candidateFailed = true; + } + } + for (const expectation of fixture.expect ?? []) { + const input = structuredClone(expectation.input); + const result = schema(input); + if (candidate.isErrors(result)) { + console.error( + `✗ ${candidate.name}/${fixture.name}: expect input rejected: ${JSON.stringify(expectation.input)} → ${candidate.summary?.(result)}`, + ); + candidateFailed = true; + continue; + } + if (!Bun.deepEquals(result, expectation.output, true)) { + console.error( + `✗ ${candidate.name}/${fixture.name}: morph output mismatch: got ${JSON.stringify(result)}, want ${JSON.stringify(expectation.output)}`, + ); + candidateFailed = true; + } + if (!Bun.deepEquals(input, expectation.input, true)) { + console.error(`✗ ${candidate.name}/${fixture.name}: schema mutated its input: ${JSON.stringify(input)}`); + candidateFailed = true; + } + } + } + correctnessFailed ||= candidateFailed; + console.log(`correctness ${candidate.name}: ${candidateFailed ? "FAILED" : "ok"}`); +} +if (correctnessFailed) process.exit(1); + +function formatNanoseconds(nanoseconds: number): string { + if (nanoseconds < 1e3) return `${nanoseconds.toFixed(0)}ns`; + if (nanoseconds < 1e6) return `${(nanoseconds / 1e3).toFixed(2)}µs`; + if (nanoseconds < 1e9) return `${(nanoseconds / 1e6).toFixed(2)}ms`; + return `${(nanoseconds / 1e9).toFixed(2)}s`; +} + +interface Row { + candidate: string; + perOperation: number; +} + +function report(title: string, rows: Row[]): void { + const baseline = rows.find(row => row.candidate === "omptype"); + console.log(`\n== ${title} ==`); + for (const row of rows) { + let relative = ""; + if (baseline && row.candidate !== "omptype") { + const factor = row.perOperation / baseline.perOperation; + relative = + factor >= 1 ? ` \x1b[31m▲ ${factor.toFixed(1)}x\x1b[0m` : ` \x1b[32m▼ ${(1 / factor).toFixed(1)}x\x1b[0m`; + } + console.log(` ${row.candidate.padEnd(12)} ${formatNanoseconds(row.perOperation).padStart(10)}/op${relative}`); + } +} + +function rewriteUnique( + entry: { def: Def; valid: unknown; invalid: unknown }, + tag: string, +): { def: Def; valid: unknown; invalid: unknown } { + const json = JSON.stringify(entry); + return JSON.parse(json.replaceAll("alpha_", `alpha${tag}_`).replaceAll("mode_", `mode${tag}_`)); +} + +{ + const count = 400; + const rows: Row[] = []; + for (const candidate of candidates) { + Bun.gc(true); + const repetitions: number[] = []; + for (let repetition = 0; repetition < 5; repetition++) { + const definitions = generateUniqueDefs(count).map((entry, index) => + rewriteUnique(entry, `r${repetition}_${index}`), + ); + const start = Bun.nanoseconds(); + for (const definition of definitions) candidate.type(definition.def); + repetitions.push((Bun.nanoseconds() - start) / count); + } + rows.push({ candidate: candidate.name, perOperation: Math.min(...repetitions) }); + } + report(`compile: type() for ${count} unique object schemas`, rows); +} + +{ + const count = 400; + const rows: Row[] = []; + for (const candidate of candidates) { + Bun.gc(true); + const repetitions: number[] = []; + for (let repetition = 0; repetition < 5; repetition++) { + const definitions = generateUniqueDefs(count).map((entry, index) => + rewriteUnique(entry, `c${repetition}_${index}`), + ); + const start = Bun.nanoseconds(); + for (const definition of definitions) { + const schema = candidate.type(definition.def); + schema(definition.valid); + schema(definition.invalid); + } + repetitions.push((Bun.nanoseconds() - start) / count); + } + rows.push({ candidate: candidate.name, perOperation: Math.min(...repetitions) }); + } + report("cold: type() + 2 validations", rows); +} + +{ + const iterations = 200_000; + for (const fixture of FIXTURES) { + const rows: Row[] = []; + const inputs = [...fixture.valid, ...fixture.invalid]; + for (const candidate of candidates) { + const schema = candidate.type(fixture.def); + for (let index = 0; index < 2_000; index++) schema(inputs[index % inputs.length]); + Bun.gc(true); + let sink = 0; + const start = Bun.nanoseconds(); + for (let index = 0; index < iterations; index++) { + const result = schema(inputs[index % inputs.length]); + if (candidate.isErrors(result)) sink++; + } + const duration = Bun.nanoseconds() - start; + if (sink === 0) throw new Error("benchmark sink was not observed"); + rows.push({ candidate: candidate.name, perOperation: duration / iterations }); + } + report(`hot: ${fixture.name} × ${iterations}`, rows); + } +} + +{ + const iterations = 200_000; + const rows: Row[] = []; + const fixture = FIXTURES[2]; + for (const candidate of candidates) { + const schema = candidate.type(fixture.def); + for (let index = 0; index < 2_000; index++) schema(fixture.valid[index % fixture.valid.length]); + Bun.gc(true); + let sink = 0; + const start = Bun.nanoseconds(); + for (let index = 0; index < iterations; index++) { + const result = schema(fixture.valid[index % fixture.valid.length]); + if (!candidate.isErrors(result)) sink++; + } + const duration = Bun.nanoseconds() - start; + if (sink === 0) throw new Error("benchmark sink was not observed"); + rows.push({ candidate: candidate.name, perOperation: duration / iterations }); + } + report(`hot valid-only: ${fixture.name} × ${iterations}`, rows); +} diff --git a/packages/omptype/bench/candidate.ts b/packages/omptype/bench/candidate.ts new file mode 100644 index 000000000..8ba4e5218 --- /dev/null +++ b/packages/omptype/bench/candidate.ts @@ -0,0 +1,18 @@ +/** + * Contract every benchmark candidate implements. + * + * `type(def)` mirrors arktype: returns a callable schema. Calling it with a + * value returns the (possibly morphed: defaults applied, extras deleted) value + * on success, or an error object recognized by `isErrors` on failure. + */ +import type { Def } from "./ir"; + +export type SchemaFn = (value: unknown) => unknown; + +export interface Candidate { + name: string; + type(def: Def): SchemaFn; + isErrors(result: unknown): boolean; + /** Optional error summary string for correctness diagnostics. */ + summary?(result: unknown): string; +} diff --git a/packages/omptype/bench/candidates/arktype.ts b/packages/omptype/bench/candidates/arktype.ts new file mode 100644 index 000000000..2664b36e5 --- /dev/null +++ b/packages/omptype/bench/candidates/arktype.ts @@ -0,0 +1,14 @@ +/** Pipeline: `type(def)` compiles; each call returns data or ArkErrors directly. */ +import { type } from "arktype"; +import type { Candidate } from "../candidate"; +import type { Def } from "../ir"; + +export const arktypeCandidate: Candidate = { + name: "arktype", + type(def: Def) { + // Runtime-generated benchmark definitions cannot preserve arktype's const generic. + return type(def as never); + }, + isErrors: result => result instanceof type.errors, + summary: result => (result instanceof type.errors ? result.summary : ""), +}; diff --git a/packages/omptype/bench/candidates/omptype.ts b/packages/omptype/bench/candidates/omptype.ts new file mode 100644 index 000000000..32793f0e8 --- /dev/null +++ b/packages/omptype/bench/candidates/omptype.ts @@ -0,0 +1,14 @@ +/** Pipeline: `type(def)` compiles lazily; each call returns data or OmpErrors directly. */ +import { OmpErrors, type } from "../../src"; +import type { Candidate } from "../candidate"; +import type { Def } from "../ir"; + +export const omptypeCandidate: Candidate = { + name: "omptype", + type(def: Def) { + // Runtime-generated benchmark definitions cannot preserve the const generic. + return type(def as never); + }, + isErrors: result => result instanceof OmpErrors, + summary: result => (result instanceof OmpErrors ? result.summary : ""), +}; diff --git a/packages/omptype/bench/candidates/typebox.ts b/packages/omptype/bench/candidates/typebox.ts new file mode 100644 index 000000000..5a538d3fc --- /dev/null +++ b/packages/omptype/bench/candidates/typebox.ts @@ -0,0 +1,150 @@ +/** + * Pipeline: Type.Object builds each schema and TypeCompiler.Compile compiles + * its hot `Check` path. Failures return TypeBox's lazy native error iterator. + * Default/delete fixtures clone first, then use the sanctioned + * Value.Default/Value.Clean transforms before Check, preserving input. + */ +import { type TSchema, Type } from "@sinclair/typebox"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; +import { ValueErrorIterator } from "@sinclair/typebox/errors"; +import { Value } from "@sinclair/typebox/value"; +import type { Candidate } from "../candidate"; +import type { Def } from "../ir"; + +type Morph = "none" | "default" | "clean"; +interface BuiltSchema { + schema: TSchema; + morph: Morph; +} + +const flatSmall = Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Integer({ minimum: 1 })), + limit: Type.Optional(Type.Integer()), +}); +const enumUnion = Type.Object({ + op: Type.Union([ + Type.Literal("start"), + Type.Literal("stop"), + Type.Literal("restart"), + Type.Literal("send"), + Type.Literal("wait"), + Type.Literal("logs"), + ]), + timeout: Type.Optional(Type.Number({ exclusiveMinimum: 0, maximum: 3600 })), + pty: Type.Optional(Type.Boolean()), +}); +const nestedArrays = Type.Object({ + context: Type.String(), + tasks: Type.Array( + Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 32 })), + agent: Type.Optional(Type.String()), + task: Type.String(), + schemaMode: Type.Optional(Type.Union([Type.Literal("permissive"), Type.Literal("strict")])), + }), + ), +}); +const strictDefaults = Type.Object( + { + action: Type.Union([Type.Literal("list"), Type.Literal("get"), Type.Literal("put")]), + key: Type.String(), + count: Type.Integer({ default: 10 }), + tags: Type.Optional(Type.Array(Type.String())), + }, + { additionalProperties: false }, +); +const deleteExtras = Type.Object( + { + name: Type.String(), + level: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("high")])), + }, + { additionalProperties: false }, +); +const recordMixed = Type.Object({ + env: Type.Record(Type.String(), Type.String()), + args: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Number()]))), + mode: Type.Optional(Type.Union([Type.Literal("a"), Type.Literal("b"), Type.Literal("c")])), + verbose: Type.Optional(Type.Union([Type.Boolean(), Type.Literal("auto")])), +}); +const deepMessage = Type.Object({ + role: Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("system")]), + content: Type.Array( + Type.Object({ + type: Type.Union([Type.Literal("text"), Type.Literal("image")]), + text: Type.Optional(Type.String()), + data: Type.Optional(Type.String()), + cache: Type.Optional(Type.Boolean()), + }), + ), + stop: Type.Optional(Type.Union([Type.Literal("end"), Type.Literal("tool"), Type.Literal("length"), Type.Null()])), + usage: Type.Optional(Type.Object({ input: Type.Number({ minimum: 0 }), output: Type.Number({ minimum: 0 }) })), +}); + +function buildGenerated(definition: Record): TSchema { + let alpha = ""; + let beta = ""; + let gamma = ""; + for (const key in definition) { + if (key.startsWith("alpha")) alpha = key; + else if (key.startsWith("beta")) beta = key; + else if (key.startsWith("gamma")) gamma = key; + } + if (!alpha || !beta || !gamma) throw new Error("unknown benchmark definition"); + const betaDefinition = definition[beta]; + const gammaDefinition = definition[gamma]; + if (typeof betaDefinition !== "string" || typeof gammaDefinition !== "string") { + throw new Error("malformed generated definition"); + } + const minimum = Number(betaDefinition.match(/>= (\d+)/)?.[1]); + const literals = [...gammaDefinition.matchAll(/'([^']+)'/g)].map(match => match[1]); + if (!Number.isFinite(minimum) || literals.length !== 2) throw new Error("malformed generated definition"); + return Type.Object({ + [alpha]: Type.String(), + [beta.slice(0, -1)]: Type.Optional(Type.Integer({ minimum })), + [gamma]: Type.Union([Type.Literal(literals[0]), Type.Literal(literals[1])]), + nested: Type.Object({ flag: Type.Boolean(), items: Type.Array(Type.String()) }), + }); +} + +function buildSchema(definition: Def): BuiltSchema { + if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { + throw new Error("benchmark candidates require object definitions"); + } + const objectDefinition = definition as Record; + if ("path" in objectDefinition) return { schema: flatSmall, morph: "none" }; + if ("op" in objectDefinition) return { schema: enumUnion, morph: "none" }; + if ("context" in objectDefinition) return { schema: nestedArrays, morph: "none" }; + if ("action" in objectDefinition) return { schema: strictDefaults, morph: "default" }; + if ("name" in objectDefinition) return { schema: deleteExtras, morph: "clean" }; + if ("env" in objectDefinition) return { schema: recordMixed, morph: "none" }; + if ("role" in objectDefinition) return { schema: deepMessage, morph: "none" }; + return { schema: buildGenerated(objectDefinition), morph: "none" }; +} + +export const typeboxCandidate: Candidate = { + name: "typebox", + type(definition) { + const { schema, morph } = buildSchema(definition); + const compiled = TypeCompiler.Compile(schema); + if (morph === "default") { + return (value: unknown) => { + const defaulted = Value.Default(schema, structuredClone(value)); + // Reject extras before Clean; otherwise strict semantics would be weakened. + if (!compiled.Check(defaulted)) return compiled.Errors(defaulted); + const output = Value.Clean(schema, defaulted); + return compiled.Check(output) ? output : compiled.Errors(output); + }; + } + if (morph === "clean") { + return (value: unknown) => { + const defaulted = Value.Default(schema, structuredClone(value)); + const output = Value.Clean(schema, defaulted); + return compiled.Check(output) ? output : compiled.Errors(output); + }; + } + return (value: unknown) => (compiled.Check(value) ? value : compiled.Errors(value)); + }, + isErrors: result => result instanceof ValueErrorIterator, + summary: result => (result instanceof ValueErrorIterator ? (result.First()?.message ?? "") : ""), +}; diff --git a/packages/omptype/bench/candidates/zod.ts b/packages/omptype/bench/candidates/zod.ts new file mode 100644 index 000000000..d38d32893 --- /dev/null +++ b/packages/omptype/bench/candidates/zod.ts @@ -0,0 +1,117 @@ +/** + * Pipeline: fixture definitions select equivalent hand-written Zod v4 schemas; + * generated compile/cold definitions build the same fixed shape. Validation uses + * safeParse, returning `success.data` (including defaults/stripping) or ZodError. + */ +import { z } from "zod/v4"; +import type { Candidate } from "../candidate"; +import type { Def } from "../ir"; + +const flatSmall = z.object({ + path: z.string(), + offset: z.number().int().min(1).optional(), + limit: z.number().int().optional(), +}); +const enumUnion = z.object({ + op: z.enum(["start", "stop", "restart", "send", "wait", "logs"]), + timeout: z.number().gt(0).max(3600).optional(), + pty: z.boolean().optional(), +}); +const nestedArrays = z.object({ + context: z.string(), + tasks: z.array( + z.object({ + name: z.string().min(1).max(32).optional(), + agent: z.string().optional(), + task: z.string(), + schemaMode: z.enum(["permissive", "strict"]).optional(), + }), + ), +}); +const strictDefaults = z + .object({ + action: z.enum(["list", "get", "put"]), + key: z.string(), + count: z.number().int().default(10), + tags: z.array(z.string()).optional(), + }) + .strict(); +const deleteExtras = z + .object({ + name: z.string(), + level: z.enum(["low", "high"]).optional(), + }) + .strip(); +const recordMixed = z.object({ + env: z.record(z.string(), z.string()), + args: z.array(z.union([z.string(), z.number()])).optional(), + mode: z.enum(["a", "b", "c"]).optional(), + verbose: z.union([z.boolean(), z.literal("auto")]).optional(), +}); +const deepMessage = z.object({ + role: z.enum(["user", "assistant", "system"]), + content: z.array( + z.object({ + type: z.enum(["text", "image"]), + text: z.string().optional(), + data: z.string().optional(), + cache: z.boolean().optional(), + }), + ), + stop: z.union([z.enum(["end", "tool", "length"]), z.null()]).optional(), + usage: z.object({ input: z.number().min(0), output: z.number().min(0) }).optional(), +}); + +function buildGenerated(definition: Record): z.ZodType { + let alpha = ""; + let beta = ""; + let gamma = ""; + for (const key in definition) { + if (key.startsWith("alpha")) alpha = key; + else if (key.startsWith("beta")) beta = key; + else if (key.startsWith("gamma")) gamma = key; + } + if (!alpha || !beta || !gamma) throw new Error("unknown benchmark definition"); + const betaDefinition = definition[beta]; + const gammaDefinition = definition[gamma]; + if (typeof betaDefinition !== "string" || typeof gammaDefinition !== "string") { + throw new Error("malformed generated definition"); + } + const minimum = Number(betaDefinition.match(/>= (\d+)/)?.[1]); + const literals = [...gammaDefinition.matchAll(/'([^']+)'/g)].map(match => match[1]); + if (!Number.isFinite(minimum) || literals.length !== 2) throw new Error("malformed generated definition"); + return z.object({ + [alpha]: z.string(), + [beta.slice(0, -1)]: z.number().int().min(minimum).optional(), + [gamma]: z.enum([literals[0], literals[1]]), + nested: z.object({ flag: z.boolean(), items: z.array(z.string()) }), + }); +} + +function buildSchema(definition: Def): z.ZodType { + if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { + throw new Error("benchmark candidates require object definitions"); + } + const objectDefinition = definition as Record; + if ("path" in objectDefinition) return flatSmall; + if ("op" in objectDefinition) return enumUnion; + if ("context" in objectDefinition) return nestedArrays; + if ("action" in objectDefinition) return strictDefaults; + if ("name" in objectDefinition) return deleteExtras; + if ("env" in objectDefinition) return recordMixed; + if ("role" in objectDefinition) return deepMessage; + return buildGenerated(objectDefinition); +} + +export const zodCandidate: Candidate = { + name: "zod", + type(definition) { + const schema = buildSchema(definition); + return (value: unknown) => { + const result = schema.safeParse(value); + return result.success ? result.data : result.error; + }; + }, + isErrors: result => result instanceof z.ZodError, + summary: result => (result instanceof z.ZodError ? z.prettifyError(result) : ""), +}; diff --git a/packages/omptype/bench/fixtures.ts b/packages/omptype/bench/fixtures.ts new file mode 100644 index 000000000..e8c14f4da --- /dev/null +++ b/packages/omptype/bench/fixtures.ts @@ -0,0 +1,152 @@ +/** Benchmark fixtures modeled on real omp tool-parameter schemas. */ +import type { Def } from "./ir"; + +export interface Fixture { + name: string; + def: Def; + valid: unknown[]; + invalid: unknown[]; + /** The schema must produce output without mutating input. */ + expect?: { input: unknown; output: unknown }[]; +} + +export const FIXTURES: Fixture[] = [ + { + name: "flat-small", + def: { path: "string", "offset?": "number.integer >= 1", "limit?": "number.integer" }, + valid: [{ path: "src/foo.ts" }, { path: "a", offset: 5, limit: 100 }], + invalid: [{ offset: 1 }, { path: "a", offset: 0.5 }, { path: 7 }], + }, + { + name: "enum-union", + def: { + op: "'start' | 'stop' | 'restart' | 'send' | 'wait' | 'logs'", + "timeout?": "0 < number <= 3600", + "pty?": "boolean", + }, + valid: [{ op: "start" }, { op: "logs", timeout: 30, pty: true }], + invalid: [{ op: "reboot" }, { op: "wait", timeout: 0 }, { op: "send", pty: 1 }], + }, + { + name: "nested-arrays", + def: { + context: "string", + tasks: [ + { + "name?": "1 <= string <= 32", + "agent?": "string", + task: "string", + "schemaMode?": "'permissive' | 'strict'", + }, + "[]", + ] as Def, + }, + valid: [ + { context: "goal", tasks: [{ task: "do it" }] }, + { context: "c", tasks: [{ name: "A", agent: "scout", task: "x", schemaMode: "strict" }, { task: "y" }] }, + ], + invalid: [ + { context: "c", tasks: [{ task: 5 }] }, + { context: "c", tasks: [{ name: "", task: "x" }] }, + { context: "c", tasks: "nope" }, + { tasks: [] }, + ], + }, + { + name: "strict-defaults", + def: { + "+": "reject", + action: "'list' | 'get' | 'put'", + key: "string", + count: "number.integer = 10", + "tags?": "string[]", + }, + valid: [ + { action: "get", key: "k" }, + { action: "put", key: "k", count: 3, tags: ["a", "b"] }, + ], + invalid: [ + { action: "get", key: "k", extra: 1 }, + { action: "get", key: "k", count: 1.5 }, + { action: "get", key: "k", tags: ["a", 2] }, + ], + expect: [ + { input: { action: "get", key: "k" }, output: { action: "get", key: "k", count: 10 } }, + { input: { action: "put", key: "k", count: 3 }, output: { action: "put", key: "k", count: 3 } }, + ], + }, + { + name: "delete-extras", + def: { "+": "delete", name: "string", "level?": "'low' | 'high'" }, + valid: [{ name: "x" }, { name: "x", junk: 1, more: "y" }, { name: "x", level: "low" }], + invalid: [{ name: 1 }, { name: "x", level: "mid" }], + expect: [ + { input: { name: "x", junk: 1, more: "y" }, output: { name: "x" } }, + { input: { name: "x", level: "high", z: null }, output: { name: "x", level: "high" } }, + ], + }, + { + name: "record-mixed", + def: { + env: { "[string]": "string" }, + "args?": "(string | number)[]", + "mode?": "'a' | 'b' | 'c'", + "verbose?": "boolean | 'auto'", + }, + valid: [{ env: {} }, { env: { A: "1", B: "2" }, args: ["x", 3], mode: "b", verbose: "auto" }], + invalid: [{ env: { A: 1 } }, { env: {}, args: [true] }, { env: {}, verbose: "always" }], + }, + { + name: "deep-message", + def: { + role: "'user' | 'assistant' | 'system'", + content: [ + { type: "'text' | 'image'", "text?": "string", "data?": "string", "cache?": "boolean" }, + "[]", + ] as Def, + "stop?": "'end' | 'tool' | 'length' | null", + "usage?": { input: "number >= 0", output: "number >= 0" }, + }, + valid: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "text", text: "a" }, + { type: "image", data: "b64", cache: true }, + ], + stop: null, + usage: { input: 10, output: 20 }, + }, + ], + invalid: [ + { role: "bot", content: [] }, + { role: "user", content: [{ type: "video" }] }, + { role: "user", content: [{ type: "text" }], usage: { input: -1, output: 0 } }, + ], + }, +]; + +/** Generate unique fixed-shape schemas plus matching validation probes. */ +export function generateUniqueDefs(count: number): { def: Def; valid: unknown; invalid: unknown }[] { + const out: { def: Def; valid: unknown; invalid: unknown }[] = []; + for (let i = 0; i < count; i++) { + const ka = `alpha_${i}`; + const kb = `beta_${i}`; + const kc = `gamma_${i}`; + const lit1 = `mode_${i}`; + const lit2 = `alt_${i}`; + const def: Def = { + [ka]: "string", + [`${kb}?`]: `number.integer >= ${i % 7}`, + [kc]: `'${lit1}' | '${lit2}'`, + nested: { flag: "boolean", items: "string[]" }, + }; + out.push({ + def, + valid: { [ka]: "x", [kb]: (i % 7) + 1, [kc]: lit1, nested: { flag: true, items: ["a"] } }, + invalid: { [ka]: "x", [kc]: "nope", nested: { flag: true, items: [] } }, + }); + } + return out; +} diff --git a/packages/omptype/bench/ir.ts b/packages/omptype/bench/ir.ts new file mode 100644 index 000000000..ea09baa63 --- /dev/null +++ b/packages/omptype/bench/ir.ts @@ -0,0 +1,287 @@ +/** Neutral IR and parser for the benchmark's arktype-compatible definitions. */ +export type IR = + | { k: "unknown" } + | { k: "null" } + | { k: "undefined" } + | { k: "boolean" } + | { k: "string"; min?: number; max?: number } + | { k: "number"; min?: number; max?: number; xmin?: boolean; xmax?: boolean; int?: boolean } + | { k: "lit"; v: string | number | boolean } + | { k: "union"; members: IR[] } + | { k: "array"; el: IR } + | { k: "object"; props: PropIR[]; index?: IR; extras: Extras }; + +export type Extras = "keep" | "reject" | "delete"; + +export interface PropIR { + key: string; + opt: boolean; + val: IR; + def?: unknown; + hasDefault?: boolean; +} + +/** Arktype-style string DSL, object literal, or `[def, "[]"]` array tuple. */ +export type Def = string | { [key: string]: Def } | readonly Def[]; + +type Token = + | { type: "id"; value: string } + | { type: "number"; value: number } + | { type: "string"; value: string } + | { type: "operator"; value: string }; + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + while (index < source.length) { + const character = source[index]; + if (character === " " || character === "\t" || character === "\n") { + index++; + continue; + } + if (character === "'" || character === '"') { + let end = index + 1; + let value = ""; + while (end < source.length && source[end] !== character) value += source[end++]; + if (end >= source.length) throw new Error(`unterminated string literal in "${source}"`); + tokens.push({ type: "string", value }); + index = end + 1; + continue; + } + if ( + (character >= "0" && character <= "9") || + (character === "-" && source[index + 1] >= "0" && source[index + 1] <= "9") + ) { + let end = index + 1; + while (end < source.length && ((source[end] >= "0" && source[end] <= "9") || source[end] === ".")) end++; + tokens.push({ type: "number", value: Number(source.slice(index, end)) }); + index = end; + continue; + } + if (/[a-zA-Z_]/.test(character)) { + let end = index + 1; + while (end < source.length && /[\w.]/.test(source[end])) end++; + tokens.push({ type: "id", value: source.slice(index, end) }); + index = end; + continue; + } + if (character === "<" || character === ">") { + const operator = source[index + 1] === "=" ? `${character}=` : character; + tokens.push({ type: "operator", value: operator }); + index += operator.length; + continue; + } + if ("|()[]=".includes(character)) { + tokens.push({ type: "operator", value: character }); + index++; + continue; + } + throw new Error(`unexpected char '${character}' in "${source}"`); + } + return tokens; +} + +const COMPARATORS = new Set(["<", "<=", ">", ">="]); + +class Parser { + readonly #tokens: Token[]; + #position = 0; + + constructor(source: string) { + this.#tokens = tokenize(source); + } + + #peek(offset = 0): Token | undefined { + return this.#tokens[this.#position + offset]; + } + + #next(): Token { + const token = this.#tokens[this.#position++]; + if (!token) throw new Error("unexpected end of definition"); + return token; + } + + #eat(operator: string): boolean { + const token = this.#peek(); + if (token?.type !== "operator" || token.value !== operator) return false; + this.#position++; + return true; + } + + parseTop(): { ir: IR; defaultValue?: unknown; hasDefault: boolean } { + const ir = this.#parseUnion(); + if (!this.#eat("=")) { + this.#expectEnd(); + return { ir, hasDefault: false }; + } + const token = this.#next(); + let defaultValue: unknown; + if (token.type === "number" || token.type === "string") defaultValue = token.value; + else if (token.type === "id" && (token.value === "true" || token.value === "false")) { + defaultValue = token.value === "true"; + } else if (token.type === "id" && token.value === "null") defaultValue = null; + else throw new Error("unsupported default literal"); + this.#expectEnd(); + return { ir, defaultValue, hasDefault: true }; + } + + #expectEnd(): void { + if (this.#position !== this.#tokens.length) throw new Error("trailing tokens in definition"); + } + + #parseUnion(): IR { + const first = this.#parseBounded(); + if (!this.#eat("|")) return first; + const members = [first, this.#parseBounded()]; + while (this.#eat("|")) members.push(this.#parseBounded()); + return { k: "union", members }; + } + + #parseBounded(): IR { + const first = this.#peek(); + const possibleComparator = this.#peek(1); + if ( + first?.type === "number" && + possibleComparator?.type === "operator" && + COMPARATORS.has(possibleComparator.value) + ) { + this.#position++; + const comparator = this.#next(); + if (comparator.type !== "operator") throw new Error("expected comparator"); + let node = applyBound(this.#parsePostfix(), flipComparator(comparator.value), first.value); + const upper = this.#peek(); + if (upper?.type === "operator" && COMPARATORS.has(upper.value)) { + this.#position++; + const value = this.#next(); + if (value.type !== "number") throw new Error("expected number after comparator"); + node = applyBound(node, upper.value, value.value); + } + return node; + } + let node = this.#parsePostfix(); + const comparator = this.#peek(); + if (comparator?.type === "operator" && COMPARATORS.has(comparator.value)) { + this.#position++; + const value = this.#next(); + if (value.type !== "number") throw new Error("expected number after comparator"); + node = applyBound(node, comparator.value, value.value); + } + return node; + } + + #parsePostfix(): IR { + let node = this.#parsePrimary(); + while (this.#eat("[")) { + if (!this.#eat("]")) throw new Error("expected ']'"); + node = { k: "array", el: node }; + } + return node; + } + + #parsePrimary(): IR { + const token = this.#next(); + if (token.type === "operator" && token.value === "(") { + const node = this.#parseUnion(); + if (!this.#eat(")")) throw new Error("expected ')'"); + return node; + } + if (token.type === "string" || token.type === "number") return { k: "lit", v: token.value }; + if (token.type !== "id") throw new Error(`unexpected token ${JSON.stringify(token)}`); + switch (token.value) { + case "string": + return { k: "string" }; + case "number": + return { k: "number" }; + case "number.integer": + return { k: "number", int: true }; + case "boolean": + return { k: "boolean" }; + case "null": + return { k: "null" }; + case "undefined": + return { k: "undefined" }; + case "unknown": + case "object": + return { k: "unknown" }; + case "true": + return { k: "lit", v: true }; + case "false": + return { k: "lit", v: false }; + default: + throw new Error(`unknown keyword "${token.value}"`); + } + } +} + +function flipComparator(operator: string): string { + switch (operator) { + case "<": + return ">"; + case "<=": + return ">="; + case ">": + return "<"; + case ">=": + return "<="; + default: + throw new Error(`bad comparator ${operator}`); + } +} + +function applyBound(node: IR, operator: string, value: number): IR { + if (node.k !== "string" && node.k !== "number" && node.k !== "array") throw new Error(`cannot bound ${node.k}`); + const target = node as { min?: number; max?: number; xmin?: boolean; xmax?: boolean }; + if (operator === ">=") { + target.min = value; + if (node.k === "number") target.xmin = false; + } else if (operator === ">") { + target.min = node.k === "number" ? value : value + 1; + if (node.k === "number") target.xmin = true; + } else if (operator === "<=") { + target.max = value; + if (node.k === "number") target.xmax = false; + } else if (operator === "<") { + target.max = node.k === "number" ? value : value - 1; + if (node.k === "number") target.xmax = true; + } + return node; +} + +/** Parse a benchmark definition into the neutral IR. */ +export function parseDef(definition: Def): IR { + if (typeof definition === "string") return new Parser(definition).parseTop().ir; + if (Array.isArray(definition)) { + if (definition.length === 2 && definition[1] === "[]") return { k: "array", el: parseDef(definition[0] as Def) }; + throw new Error('unsupported tuple definition (only [def, "[]"])'); + } + if (typeof definition !== "object" || definition === null) + throw new Error(`unsupported definition: ${String(definition)}`); + const props: PropIR[] = []; + let index: IR | undefined; + let extras: Extras = "keep"; + for (const rawKey of Object.keys(definition)) { + const value = (definition as Record)[rawKey]; + if (rawKey === "+") { + if (value !== "reject" && value !== "delete" && value !== "ignore") + throw new Error(`bad "+" value ${String(value)}`); + extras = value === "ignore" ? "keep" : value; + continue; + } + if (rawKey === "[string]") { + index = parseDef(value); + continue; + } + const opt = rawKey.endsWith("?"); + const key = opt ? rawKey.slice(0, -1) : rawKey; + if (typeof value === "string") { + const parsed = new Parser(value).parseTop(); + const property: PropIR = { key, opt, val: parsed.ir }; + if (parsed.hasDefault) { + property.def = parsed.defaultValue; + property.hasDefault = true; + } + props.push(property); + } else props.push({ key, opt, val: parseDef(value) }); + } + return { k: "object", props, index, extras }; +} diff --git a/packages/omptype/package.json b/packages/omptype/package.json new file mode 100644 index 000000000..eaf68e459 --- /dev/null +++ b/packages/omptype/package.json @@ -0,0 +1,60 @@ +{ + "type": "module", + "name": "@oh-my-pi/omptype", + "version": "17.2.6", + "description": "ArkType-compatible runtime schema validation with lazy JIT compilation", + "homepage": "https://omp.sh", + "author": "Can Boluk", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/can1357/oh-my-pi.git", + "directory": "packages/omptype" + }, + "bugs": { + "url": "https://github.com/can1357/oh-my-pi/issues" + }, + "keywords": [ + "schema", + "validation", + "arktype", + "jit" + ], + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "check": "biome check . && bun run check:types", + "check:types": "tsgo -p tsconfig.json --noEmit", + "lint": "biome lint .", + "fix": "biome check --write --unsafe .", + "fmt": "biome format --write .", + "test": "bun test --parallel", + "bench": "bun bench/bench.ts" + }, + "devDependencies": { + "@sinclair/typebox": "^0.34.0", + "@types/bun": "catalog:", + "arktype": "2.2.3", + "zod": "catalog:" + }, + "engines": { + "node": ">=20", + "bun": ">=1.3.14" + }, + "files": [ + "src", + "README.md", + "CHANGELOG.md" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "import": "./src/*.ts" + }, + "./*.js": "./src/*.ts" + } +} diff --git a/packages/omptype/src/ark.ts b/packages/omptype/src/ark.ts new file mode 100644 index 000000000..1002e00ac --- /dev/null +++ b/packages/omptype/src/ark.ts @@ -0,0 +1,20 @@ +/** + * ArkType compatibility facade — `@oh-my-pi/omptype/ark`. + * + * Lets code written against arktype keep its imports and names while running + * on the omptype lazy-JIT runtime: swap `from "arktype"` for + * `from "@oh-my-pi/omptype/ark"` and nothing else changes. New code should + * import `@oh-my-pi/omptype` directly. + * + * Compatibility affordance: `ArkError` / `ArkErrors` alias `OmpError` / + * `OmpErrors`. All schema builders, including recursive `scope()`, are + * re-exported unchanged. + */ +import { OmpError, OmpErrors } from "./errors"; + +export * from "./index"; + +export const ArkError = OmpError; +export type ArkError = OmpError; +export const ArkErrors = OmpErrors; +export type ArkErrors = OmpErrors; diff --git a/packages/omptype/src/compile.ts b/packages/omptype/src/compile.ts new file mode 100644 index 000000000..083b4ecb1 --- /dev/null +++ b/packages/omptype/src/compile.ts @@ -0,0 +1,655 @@ +/** + * JIT compiler: lowers schema IR into a specialized validator via + * `new Function`. Invoked by `type.ts` after a schema's third call; the + * interpreter (`interp.ts`) covers earlier calls so rarely-used schemas never + * pay codegen cost. + * + * Generated code philosophy: + * - success path is straight-line monomorphic JS with zero allocation; when + * the schema has no morphs the input value itself is returned + * - failure allocates a single `OmpErrors` (`E(path, expected, data)`); path + * arrays and messages are inline literals, so cost is one small allocation + * - morphing nodes (defaults, `"+": "delete"`, embedded stepped schemas) + * produce a fresh output object; pure subtrees below them stay check-only + * - morphing union members use separately compiled or hoisted runners; pure + * members compile to inline predicates + */ +import { MISSING, OmpErrors } from "./errors"; +import { canRefineUnionFailure, unionFail, walk } from "./interp"; +import { expectedOf, hasMorph, type IR, type MorphContext, type PropIR, type TupleIR } from "./ir"; + +const own = Object.prototype.hasOwnProperty; + +const IDENT = /^[A-Za-z_$][\w$]*$/; + +function access(base: string, key: string): string { + return IDENT.test(key) ? `${base}.${key}` : `${base}[${JSON.stringify(key)}]`; +} + +/** Inline-able literal, else undefined (caller hoists into the refs pool). */ +function litSource(v: unknown): string | undefined { + if (v === null) return "null"; + if (v === undefined) return "undefined"; + switch (typeof v) { + case "string": + case "boolean": + return JSON.stringify(v); + case "number": + return Number.isFinite(v) ? String(v) : undefined; + default: + return undefined; + } +} + +type PathSeg = { s: PropertyKey } | { d: string }; + +class CompiledMorphContext implements MorphContext { + #path: PropertyKey[] | PropertyKey | undefined; + #data: unknown; + + constructor(path: PropertyKey[] | PropertyKey | undefined, data: unknown) { + this.#path = path; + this.#data = data; + } + + error(expectation: string): OmpErrors { + return new OmpErrors(this.#path, expectation, this.#data); + } + + reject(expectation: string): OmpErrors { + return this.error(expectation); + } +} + +class Builder { + #lines: string[] = []; + #refs: unknown[] = []; + #activeAliases: Set | undefined; + #id = 0; + + next(prefix: string): string { + return `${prefix}${this.#id++}`; + } + + push(line: string): void { + this.#lines.push(line); + } + + ref(value: unknown): string { + const idx = this.#refs.indexOf(value); + if (idx >= 0) return `R[${idx}]`; + this.#refs.push(value); + return `R[${this.#refs.length - 1}]`; + } + + lit(v: unknown): string { + return litSource(v) ?? this.ref(v); + } + + pathExpr(segs: PathSeg[]): string { + const parts = segs.map(seg => ("s" in seg ? JSON.stringify(seg.s) : seg.d)); + return `[${parts.join(",")}]`; + } + + storedPathExpr(segs: PathSeg[]): string { + if (segs.length === 0) return "undefined"; + if (segs.length === 1) { + const seg = segs[0]; + return "s" in seg ? JSON.stringify(seg.s) : seg.d; + } + return this.pathExpr(segs); + } + + fail(segs: PathSeg[], expected: string, dataExpr: string): string { + return `return new AE(${this.storedPathExpr(segs)},${JSON.stringify(expected)},${dataExpr})`; + } + + /** Pure boolean predicate for a morph-free subtree. */ + predicate(node: IR, v: string): string { + switch (node.k) { + case "unknown": + return "true"; + case "null": + return `${v}===null`; + case "undefined": + return `${v}===undefined`; + case "boolean": + return `typeof ${v}==="boolean"`; + case "bigint": + return `typeof ${v}==="bigint"`; + case "symbol": + return `typeof ${v}==="symbol"`; + case "never": + return "false"; + case "anyobject": + return `(typeof ${v}==="object"&&${v}!==null)`; + case "lit": + return node.v instanceof Date + ? `(${v} instanceof Date&&${v}.valueOf()===${node.v.valueOf()})` + : `${v}===${this.lit(node.v)}`; + case "instance": + return `${v} instanceof ${this.ref(node.ctor)}`; + case "string": { + let out = `typeof ${v}==="string"`; + if (node.min !== undefined) out += `&&${v}.length>=${node.min}`; + if (node.max !== undefined) out += `&&${v}.length<=${node.max}`; + if (node.url) out += `&&URL.canParse(${v})`; + return out; + } + case "number": { + let out = node.int ? `Number.isInteger(${v})` : `Number.isFinite(${v})`; + if (node.divisor !== undefined) out += `&&${v}%${node.divisor}===0`; + if (node.min !== undefined) out += `&&${v}${node.xmin ? ">" : ">="}${node.min}`; + if (node.max !== undefined) out += `&&${v}${node.xmax ? "<" : "<="}${node.max}`; + return out; + } + case "union": { + const lits = node.members.filter( + (member): member is Extract => + member.k === "lit" && + (member.v === null || (typeof member.v !== "object" && typeof member.v !== "function")), + ); + if (lits.length > 8) { + const values = this.ref(new Set(lits.map(member => member.v))); + const literalNodes = new Set(lits); + const rest = node.members.filter(member => !literalNodes.has(member)); + let out = `${values}.has(${v})`; + for (const m of rest) out += `||(${this.predicate(m, v)})`; + return `(${out})`; + } + return `(${node.members.map(m => `(${this.predicate(m, v)})`).join("||")})`; + } + case "intersection": + return `(${node.members.map(member => `(${this.predicate(member, v)})`).join("&&")})`; + case "array": { + const x = this.next("x"); + let out = `Array.isArray(${v})`; + if (node.min !== undefined) out += `&&${v}.length>=${node.min}`; + if (node.max !== undefined) out += `&&${v}.length<=${node.max}`; + out += `&&${v}.every(${x}=>${this.predicate(node.el, x)})`; + return out; + } + case "object": { + const checks = [`typeof ${v}==="object"`, `${v}!==null`, `!Array.isArray(${v})`]; + for (const p of node.props) { + const av = access(v, p.key); + const present = `${JSON.stringify(p.key)} in ${v}`; + checks.push( + p.opt || p.hasDefault + ? `(!(${present})||(${this.predicate(p.val, av)}))` + : `((${present})&&(${this.predicate(p.val, av)}))`, + ); + } + if (node.index) { + const k = this.next("k"); + checks.push( + `(()=>{for(const ${k} in ${v})if(own.call(${v},${k})&&!(${this.predicate(node.index, `${v}[${k}]`)}))return false;return true})()`, + ); + } else if (node.extras === "reject") { + const k = this.next("k"); + checks.push( + `(()=>{for(const ${k} in ${v})if(own.call(${v},${k})&&!(${this.declaredCheck(node.props, k)}))return false;return true})()`, + ); + } + return `(${checks.join("&&")})`; + } + case "sub": + return `!(${this.ref(node.schema.run)}(${v}) instanceof AE)`; + default: + return `!(${this.ref(boundWalk(node))}(${v}) instanceof AE)`; + } + } + + declaredCheck(props: PropIR[], keyVar: string): string { + if (props.length === 0) return "false"; + if (props.length > 6) { + const set = this.ref(new Set(props.map(p => p.key))); + return `${set}.has(${keyVar})`; + } + return `(${props.map(p => `${keyVar}===${JSON.stringify(p.key)}`).join("||")})`; + } + + emitDelegate(node: IR, v: string, segs: PathSeg[], out?: string): void { + const runner = node.k === "sub" ? node.schema.run : boundWalk(node); + const result = this.next("r"); + this.push(`const ${result}=${this.ref(runner)}(${v});`); + this.push( + segs.length === 0 + ? `if(${result} instanceof AE)return ${result};` + : `if(${result} instanceof AE)return PF(${result},${this.pathExpr(segs)});`, + ); + if (out !== undefined) this.push(`${out}=${result};`); + } + + emitTupleShape( + node: TupleIR, + v: string, + segs: PathSeg[], + failureData: string, + ): { postfixStart: string; prefixCount: string; requiredPrefix: number } { + this.push(`if(!Array.isArray(${v}))${this.fail(segs, "an array", failureData)};`); + const requiredPrefix = node.prefix.filter(item => !item.opt && !item.hasDefault).length; + const minimum = requiredPrefix + node.postfix.length; + if (minimum > 0) { + this.push( + `if(${v}.length<${minimum})${this.fail(segs, `an array of at least length ${minimum}`, failureData)};`, + ); + } + if (node.variadic === undefined) { + const maximum = node.prefix.length + node.postfix.length; + this.push( + `if(${v}.length>${maximum})${this.fail(segs, `an array of at most length ${maximum}`, failureData)};`, + ); + } + let postfixStart = `${v}.length`; + if (node.postfix.length > 0) { + postfixStart = this.next("p"); + this.push(`const ${postfixStart}=${v}.length-${node.postfix.length};`); + } + let prefixCount = String(node.prefix.length); + if (requiredPrefix !== node.prefix.length) { + prefixCount = this.next("n"); + this.push(`const ${prefixCount}=Math.min(${node.prefix.length},${postfixStart});`); + } + return { postfixStart, prefixCount, requiredPrefix }; + } + + /** Statement-form check for a morph-free subtree with precise error paths. */ + emitCheck(node: IR, v: string, segs: PathSeg[], failureData = v): void { + switch (node.k) { + case "unknown": + return; + case "array": { + let head = `Array.isArray(${v})`; + if (node.min !== undefined) head += `&&${v}.length>=${node.min}`; + if (node.max !== undefined) head += `&&${v}.length<=${node.max}`; + this.push(`if(!(${head}))${this.fail(segs, expectedOf(node), failureData)};`); + const i = this.next("i"); + const x = this.next("x"); + this.push(`for(let ${i}=0;${i}<${v}.length;${i}++){const ${x}=${v}[${i}];`); + this.emitCheck(node.el, x, [...segs, { d: i }]); + this.push("}"); + return; + } + case "tuple": { + const { postfixStart, prefixCount, requiredPrefix } = this.emitTupleShape(node, v, segs, failureData); + for (let index = 0; index < node.prefix.length; index++) { + if (index >= requiredPrefix) this.push(`if(${index}<${prefixCount}){`); + this.emitCheck(node.prefix[index].val, `${v}[${index}]`, [...segs, { d: String(index) }]); + if (index >= requiredPrefix) this.push("}"); + } + if (node.variadic !== undefined) { + const index = this.next("i"); + this.push(`for(let ${index}=${prefixCount};${index}<${postfixStart};${index}++){`); + this.emitCheck(node.variadic, `${v}[${index}]`, [...segs, { d: index }]); + this.push("}"); + } + for (let index = 0; index < node.postfix.length; index++) { + const inputIndex = index === 0 ? postfixStart : `${postfixStart}+${index}`; + this.emitCheck(node.postfix[index], `${v}[${inputIndex}]`, [...segs, { d: inputIndex }]); + } + return; + } + case "object": { + this.push( + `if(typeof ${v}!=="object"||${v}===null||Array.isArray(${v}))${this.fail(segs, "an object", failureData)};`, + ); + for (const p of node.props) { + const present = `${JSON.stringify(p.key)} in ${v}`; + const propSegs: PathSeg[] = [...segs, { s: p.key }]; + if (p.opt || p.hasDefault) { + this.push(`if(${present}){`); + this.emitCheck(p.val, access(v, p.key), propSegs); + this.push("}"); + } else { + this.push(`if(!(${present}))${this.fail(propSegs, expectedOf(p.val), "M")};`); + this.emitCheck(p.val, access(v, p.key), propSegs); + } + } + if (node.index) { + const k = this.next("k"); + this.push(`for(const ${k} in ${v})if(own.call(${v},${k})){`); + this.emitCheck(node.index, `${v}[${k}]`, [...segs, { d: k }]); + this.push("}"); + } else if (node.extras === "reject") { + const k = this.next("k"); + this.push( + `for(const ${k} in ${v})if(own.call(${v},${k})&&!(${this.declaredCheck(node.props, k)}))${this.fail( + [...segs, { d: k }], + "removed (undeclared key)", + `${v}[${k}]`, + )};`, + ); + } + return; + } + case "sub": + this.emitDelegate(node, v, segs); + return; + case "union": { + const failure = node.members.some(canRefineUnionFailure) + ? `return UF(${this.ref(node)},${failureData},${this.pathExpr(segs)},${JSON.stringify(expectedOf(node))})` + : this.fail(segs, expectedOf(node), failureData); + this.push(`if(!(${this.predicate(node, v)}))${failure};`); + return; + } + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "never": + case "anyobject": + case "lit": + case "string": + case "number": + case "instance": + this.push(`if(!(${this.predicate(node, v)}))${this.fail(segs, expectedOf(node), failureData)};`); + return; + case "refine": { + this.emitCheck(node.base, v, segs, failureData); + const failure = this.fail(segs, node.expected, v); + this.push(`try{if(!${this.ref(node.pred)}(${v}))${failure};}catch{${failure};}`); + return; + } + case "intersection": + for (const member of node.members) this.emitCheck(member, v, segs, failureData); + return; + default: + this.emitDelegate(node, v, segs); + } + } + + /** + * Validate `v` against a morphing subtree and assign the produced output + * to `out` (an already-declared `let`). + */ + emitProduce(node: IR, v: string, segs: PathSeg[], out: string, failureData = v): void { + if (!hasMorph(node)) { + this.emitCheck(node, v, segs, failureData); + this.push(`${out}=${v};`); + return; + } + switch (node.k) { + case "sub": + this.emitDelegate(node, v, segs, out); + return; + case "morph": { + const input = this.next("t"); + this.push(`let ${input};`); + this.emitProduce(node.input, v, segs, input, failureData); + const context = this.next("c"); + const result = this.next("r"); + this.push(`const ${context}=new MC(${this.storedPathExpr(segs)},${input});`); + this.push(`const ${result}=${this.ref(node.fn)}(${input},${context});`); + this.push(`if(${result} instanceof AE)return ${result};`); + if (node.out === undefined) this.push(`${out}=${result};`); + else this.emitProduce(node.out, result, segs, out); + return; + } + case "alias": { + let active = this.#activeAliases; + if (active === undefined) { + active = new Set(); + this.#activeAliases = active; + } + if (active.has(node)) { + this.emitDelegate(node, v, segs, out); + return; + } + active.add(node); + try { + this.emitProduce(node.resolve(), v, segs, out, failureData); + } finally { + active.delete(node); + } + return; + } + case "union": { + const ok = this.next("u"); + this.push(`let ${ok}=false;`); + const label = this.next("b"); + this.push(`${label}:{`); + for (const m of node.members) { + if (m.k !== "sub" && !hasMorph(m)) { + this.push(`if(${this.predicate(m, v)}){${out}=${v};${ok}=true;break ${label};}`); + } + } + for (const m of node.members) { + if (m.k === "sub" || hasMorph(m)) { + const runner = m.k === "sub" ? m.schema.run : m.k === "alias" ? boundWalk(m) : compile(m); + const r = this.next("r"); + this.push(`const ${r}=${this.ref(runner)}(${v});`); + this.push(`if(!(${r} instanceof AE)){${out}=${r};${ok}=true;break ${label};}`); + } + } + this.push("}"); + this.push( + `if(!${ok})return UF(${this.ref(node)},${failureData},${this.pathExpr(segs)},${JSON.stringify(expectedOf(node))});`, + ); + return; + } + case "array": { + let head = `Array.isArray(${v})`; + if (node.min !== undefined) head += `&&${v}.length>=${node.min}`; + if (node.max !== undefined) head += `&&${v}.length<=${node.max}`; + this.push(`if(!(${head}))${this.fail(segs, expectedOf(node), failureData)};`); + const arr = this.next("a"); + const i = this.next("i"); + const x = this.next("x"); + const el = this.next("t"); + this.push(`const ${arr}=new Array(${v}.length);`); + this.push(`for(let ${i}=0;${i}<${v}.length;${i}++){const ${x}=${v}[${i}];let ${el};`); + this.emitProduce(node.el, x, [...segs, { d: i }], el); + this.push(`${arr}[${i}]=${el};}`); + this.push(`${out}=${arr};`); + return; + } + case "tuple": { + const { postfixStart, prefixCount, requiredPrefix } = this.emitTupleShape(node, v, segs, failureData); + const tuple = this.next("a"); + this.push(`const ${tuple}=[...${v}];`); + for (let index = 0; index < node.prefix.length; index++) { + const item = node.prefix[index]; + const input = `${v}[${index}]`; + const output = `${tuple}[${index}]`; + if (index >= requiredPrefix) this.push(`if(${index}<${prefixCount}){`); + if (hasMorph(item.val)) this.emitProduce(item.val, input, [...segs, { d: String(index) }], output); + else { + this.emitCheck(item.val, input, [...segs, { d: String(index) }]); + this.push(`${output}=${input};`); + } + if (index >= requiredPrefix) { + if (item.hasDefault) { + const defaultValue = item.defFactory ? `${this.ref(item.def)}()` : this.lit(item.def); + this.push(`}else{${output}=${defaultValue};}`); + } else { + this.push("}"); + } + } + } + if (node.variadic !== undefined) { + const index = this.next("i"); + const input = this.next("x"); + this.push( + `for(let ${index}=${prefixCount};${index}<${postfixStart};${index}++){const ${input}=${v}[${index}];`, + ); + if (hasMorph(node.variadic)) { + this.emitProduce(node.variadic, input, [...segs, { d: index }], `${tuple}[${index}]`); + } else { + this.emitCheck(node.variadic, input, [...segs, { d: index }]); + this.push(`${tuple}[${index}]=${input};`); + } + this.push("}"); + } + for (let index = 0; index < node.postfix.length; index++) { + const inputIndex = index === 0 ? postfixStart : `${postfixStart}+${index}`; + const input = `${v}[${inputIndex}]`; + const output = `${tuple}[${inputIndex}]`; + const item = node.postfix[index]; + if (hasMorph(item)) this.emitProduce(item, input, [...segs, { d: inputIndex }], output); + else { + this.emitCheck(item, input, [...segs, { d: inputIndex }]); + this.push(`${output}=${input};`); + } + } + this.push(`${out}=${tuple};`); + return; + } + case "object": { + this.push( + `if(typeof ${v}!=="object"||${v}===null||Array.isArray(${v}))${this.fail(segs, "an object", failureData)};`, + ); + const o = this.next("o"); + const fresh = node.extras !== "keep" && !node.index; + this.push(fresh ? `const ${o}={};` : `const ${o}={...${v}};`); + for (const p of node.props) { + const present = `${JSON.stringify(p.key)} in ${v}`; + const propSegs: PathSeg[] = [...segs, { s: p.key }]; + const av = access(v, p.key); + const ao = access(o, p.key); + const morphChild = hasMorph(p.val); + const missing: string[] = []; + if (p.hasDefault) { + const dflt = p.defFactory ? `${this.ref(p.def)}()` : this.lit(p.def); + missing.push(`${ao}=${dflt};`); + } else if (!p.opt) { + missing.push(`${this.fail(propSegs, expectedOf(p.val), "M")};`); + } + this.push(`if(!(${present})){${missing.join("")}}else{`); + if (morphChild) { + const t = this.next("t"); + this.push(`let ${t};`); + this.emitProduce(p.val, av, propSegs, t); + this.push(`${ao}=${t};`); + } else { + this.emitCheck(p.val, av, propSegs); + if (fresh) this.push(`${ao}=${av};`); + } + this.push("}"); + } + if (node.index) { + const k = this.next("k"); + this.push(`for(const ${k} in ${v})if(own.call(${v},${k})){`); + if (hasMorph(node.index)) { + const t = this.next("t"); + this.push(`let ${t};`); + this.emitProduce(node.index, `${v}[${k}]`, [...segs, { d: k }], t); + this.push(`${o}[${k}]=${t};`); + } else { + this.emitCheck(node.index, `${v}[${k}]`, [...segs, { d: k }]); + } + this.push("}"); + } else if (node.extras === "reject") { + const k = this.next("k"); + this.push( + `for(const ${k} in ${v})if(own.call(${v},${k})&&!(${this.declaredCheck(node.props, k)}))${this.fail( + [...segs, { d: k }], + "removed (undeclared key)", + `${v}[${k}]`, + )};`, + ); + } + this.push(`${out}=${o};`); + return; + } + case "refine": { + const refined = this.next("t"); + this.push(`let ${refined};`); + this.emitProduce(node.base, v, segs, refined, failureData); + const failure = this.fail(segs, node.expected, refined); + this.push(`try{if(!${this.ref(node.pred)}(${refined}))${failure};}catch{${failure};}`); + this.push(`${out}=${refined};`); + return; + } + case "intersection": { + const current = this.next("t"); + this.push(`let ${current}=${v};`); + for (const member of node.members) { + if (hasMorph(member)) this.emitProduce(member, current, segs, current); + else this.emitCheck(member, current, segs); + } + this.push(`${out}=${current};`); + return; + } + default: + this.emitDelegate(node, v, segs, out); + } + } + + build(ir: IR): (value: unknown) => unknown { + let ret: string; + if (hasMorph(ir)) { + this.push("let o;"); + // body emitted below needs `o` declared first, so splice ordering: + this.emitProduce(ir, "v", [], "o"); + ret = "o"; + } else { + this.emitCheck(ir, "v", []); + ret = "v"; + } + const src = `return function(v){${this.#lines.join("")}return ${ret}}`; + const make = new Function("R", "AE", "M", "PF", "UF", "MC", "own", src) as ( + refs: unknown[], + ae: typeof OmpErrors, + m: typeof MISSING, + pf: typeof prefixErrors, + uf: typeof unionFail, + mc: typeof CompiledMorphContext, + ownFn: typeof own, + ) => (value: unknown) => unknown; + return make(this.#refs, OmpErrors, MISSING, prefixErrors, unionFail, CompiledMorphContext, own); + } +} + +function prefixErrors(errs: OmpErrors, parts: PropertyKey[]): OmpErrors { + for (let i = parts.length - 1; i >= 0; i--) errs.prefix(parts[i]); + return errs; +} + +const kWalk = Symbol("omptype.boundWalk"); + +interface WalkTagged { + [kWalk]?: (value: unknown) => unknown; +} + +/** Cached interpreter closure for recursive aliases and predicate-only fallbacks. */ +function boundWalk(node: IR): (value: unknown) => unknown { + const tagged = node as IR & WalkTagged; + let fn = tagged[kWalk]; + if (!fn) { + fn = (value: unknown) => walk(node, value); + tagged[kWalk] = fn; + } + return fn; +} + +function resolvedRoot(ir: IR): IR { + return ir.k === "alias" ? ir.resolve() : ir; +} + +const compiledCache = new WeakMap unknown>(); + +/** Compile `ir` into a specialized validator. */ +export function compile(ir: IR): (value: unknown) => unknown { + const root = resolvedRoot(ir); + let validator = compiledCache.get(root); + if (validator === undefined) { + validator = new Builder().build(root); + compiledCache.set(root, validator); + } + return validator; +} + +/** Generated source for inspection/debugging. */ +export function compileToSource(ir: IR): string { + const root = resolvedRoot(ir); + const builder = new Builder(); + if (hasMorph(root)) { + builder.push("let o;"); + builder.emitProduce(root, "v", [], "o"); + return `function(v){/* refs elided */return o}`; + } + builder.emitCheck(root, "v", []); + return `function(v){/* refs elided */return v}`; +} diff --git a/packages/omptype/src/errors.ts b/packages/omptype/src/errors.ts new file mode 100644 index 000000000..3ee7c0c7b --- /dev/null +++ b/packages/omptype/src/errors.ts @@ -0,0 +1,248 @@ +/** + * Validation error containers mirroring ArkType's observable error surface: + * `result instanceof type.errors` / `instanceof OmpErrors`, lazy `.summary`, + * array iteration, and per-entry `.path` / `.problem` / `.message`. + * + * Failure-path cost matters: schemas reject untrusted input constantly, so + * construction stores only the path, the expectation, and the offending value. + * All human-readable strings are built lazily on property access. + */ + +/** Context supplied to configurable error formatters. */ +export interface ErrorContext { + readonly code: string; + readonly path: readonly PropertyKey[]; + readonly data: unknown; + readonly expected: string; + readonly actual: string; + readonly problem: string; +} + +/** Per-schema overrides for validation error text. */ +export interface ErrorConfig { + readonly expected?: string | ((context: ErrorContext) => string); + readonly actual?: string | ((context: ErrorContext) => string); + readonly problem?: string | ((context: ErrorContext) => string); + readonly message?: string | ((context: ErrorContext) => string); +} + +function format( + override: string | ((context: ErrorContext) => string) | undefined, + context: ErrorContext, + fallback: string, +): string { + return typeof override === "function" ? override(context) : (override ?? fallback); +} + +/** A single validation failure at one path. */ +export class OmpError { + #rawExpected: string; + #config: ErrorConfig | undefined; + + constructor( + /** Property path from the root to the failing value (empty at root). */ + readonly path: PropertyKey[], + expected: string, + /** The value that failed validation. */ + readonly data: unknown, + config?: ErrorConfig, + ) { + this.#rawExpected = expected; + this.#config = config; + } + + /** Stable category for programmatic error handling. */ + get code(): string { + return errorCode(this.#rawExpected, this.data); + } + + #context(expected: string, actual: string, problem = ""): ErrorContext { + return { code: this.code, path: this.path, data: this.data, expected, actual, problem }; + } + + /** Human-readable expectation, including a configured override. */ + get expected(): string { + const actual = describeValue(this.data); + return format(this.#config?.expected, this.#context(this.#rawExpected, actual), this.#rawExpected); + } + + /** Short description of the received value, e.g. `"a number"` or `"missing"`. */ + get actual(): string { + const actual = describeValue(this.data); + return format(this.#config?.actual, this.#context(this.expected, actual), actual); + } + + /** Path-less problem statement: `must be (was )`. */ + get problem(): string { + const expected = this.expected; + const actual = this.actual; + const fallback = + this.data === MISSING ? `must be ${expected} (was missing)` : `must be ${expected} (was ${actual})`; + return format(this.#config?.problem, this.#context(expected, actual, fallback), fallback); + } + + /** Full message including the path prefix. */ + get message(): string { + const expected = this.expected; + const actual = this.actual; + const problem = this.problem; + const at = this.path.length === 0 ? "" : `${this.path.map(String).join(".")} `; + return format(this.#config?.message, this.#context(expected, actual, problem), `${at}${problem}`); + } + + toString(): string { + return this.message; + } +} + +/** Sentinel for a required key that was absent (distinguishes from `undefined`). */ +export const MISSING: unique symbol = Symbol("omptype.missing"); + +function describeValue(data: unknown): string { + if (data === null) return "null"; + if (Array.isArray(data)) return "an array"; + switch (typeof data) { + case "string": + return data.length <= 40 ? JSON.stringify(data) : `a string (length ${data.length})`; + case "number": + return String(data); + case "bigint": + return `${data}n`; + case "boolean": + return String(data); + case "undefined": + return "undefined"; + case "object": + return "an object"; + case "function": + return "a function"; + default: + return "a symbol"; + } +} + +function errorCode(expected: string, data: unknown): string { + if (data === MISSING) return "required"; + if (expected.includes("divisible by")) return "divisor"; + if (expected.includes("at least") || expected.includes("more than")) return "min"; + if (expected.includes("at most") || expected.includes("less than")) return "max"; + if (expected.includes("matching") || expected.includes("format") || expected.includes("email")) return "pattern"; + if (expected.includes("predicate") || expected.includes("satisfying")) return "predicate"; + if (expected.startsWith('"') || expected.startsWith("the date ")) return "unit"; + return "domain"; +} + +/** + * Single-failure validation result with a lazy array-like entry. + * + * Validators fast-fail, so allocating an `Array` subclass and a separate entry + * on every rejection only penalizes callers that inspect errors by identity. + * Indexing, iteration, and `map` materialize the entry on demand. + */ +type StoredPath = PropertyKey[] | PropertyKey | undefined; + +export class OmpErrors implements Iterable { + #path: StoredPath; + #expected: string; + #data: unknown; + #entry: OmpError | undefined; + #config: ErrorConfig | undefined; + + /** Number of failures; omptype validators fast-fail on the first error. */ + readonly length = 1; + + constructor(path: StoredPath, expected: string, data: unknown, config?: ErrorConfig) { + this.#path = path; + this.#expected = expected; + this.#data = data; + this.#config = config; + } + + /** First and only validation failure, materialized on demand. */ + get 0(): OmpError { + return this.#getEntry(); + } + + static single(path: PropertyKey[], expected: string, data: unknown, config?: ErrorConfig): OmpErrors { + return new OmpErrors(path, expected, data, config); + } + + #getEntry(): OmpError { + if (this.#entry) return this.#entry; + const path = this.#path === undefined ? [] : Array.isArray(this.#path) ? this.#path : [this.#path]; + const entry = new OmpError(path, this.#expected, this.#data, this.#config); + this.#entry = entry; + return entry; + } + + /** Prefix the failure path with `key` when nesting sub-schemas. */ + prefix(key: PropertyKey): this { + const path = this.#path; + this.#path = path === undefined ? [key] : Array.isArray(path) ? [key, ...path] : [key, path]; + this.#entry = undefined; + return this; + } + + /** Apply schema-local message formatting without rebuilding the failure. */ + configure(config: ErrorConfig): this { + this.#config = { ...this.#config, ...config }; + this.#entry = undefined; + return this; + } + + /** Index the failure by its dotted property path (`""` for the root). */ + get byPath(): Readonly> { + const entry = this.#getEntry(); + return { [entry.path.map(String).join(".")]: entry }; + } + + /** Transform the failure entry into a plain array. */ + map(fn: (error: OmpError, index: number, errors: OmpErrors) => result): result[] { + return [fn(this.#getEntry(), 0, this)]; + } + + /** Select the failure entry into a plain array. */ + filter(fn: (error: OmpError, index: number, errors: OmpErrors) => unknown): OmpError[] { + const entry = this.#getEntry(); + return fn(entry, 0, this) ? [entry] : []; + } + + /** Iterate over the single failure entry. */ + *[Symbol.iterator](): IterableIterator { + yield this.#getEntry(); + } + + /** Human-readable failure text, materialized only when requested. */ + get summary(): string { + return this.#getEntry().message; + } + + toString(): string { + return this.summary; + } + + /** Throw a `TraversalError` carrying this result. */ + throw(): never { + throw new TraversalError(this); + } +} + +/** Error thrown by `Type.assert` on invalid input. */ +export class TraversalError extends Error { + constructor(readonly errors: OmpErrors) { + super(errors.summary); + this.name = "TraversalError"; + } +} + +/** + * Definition/usage error thrown while building a schema — malformed string + * DSL, unsupported composition, or an illegal builder call. Distinct from + * validation failures, which are returned as {@link OmpErrors}. + */ +export class OmpTypeError extends Error { + constructor(message: string) { + super(message); + this.name = "OmpTypeError"; + } +} diff --git a/packages/omptype/src/index.ts b/packages/omptype/src/index.ts new file mode 100644 index 000000000..1d4942c12 --- /dev/null +++ b/packages/omptype/src/index.ts @@ -0,0 +1,12 @@ +/** + * omptype — ArkType-compatible schema validation with a lazy JIT runtime. + * + * ArkType-compatible `type()`/`Type`, keyword modules, recursive scopes, + * composition and morph APIs, structured errors, input/output inference, and + * JSON Schema emission. + */ +export * from "./errors"; +export * from "./infer"; +export * from "./ir"; +export * from "./json-schema"; +export * from "./type"; diff --git a/packages/omptype/src/infer.ts b/packages/omptype/src/infer.ts new file mode 100644 index 000000000..e81a1a93e --- /dev/null +++ b/packages/omptype/src/infer.ts @@ -0,0 +1,329 @@ +/** Type-level input and output inference for definitions accepted by omptype. */ + +type Whitespace = " " | "\n" | "\r" | "\t"; + +type TrimLeft = s extends `${Whitespace}${infer rest}` ? TrimLeft : s; +type TrimRight = s extends `${infer rest}${Whitespace}` ? TrimRight : s; +type Trim = TrimLeft>; + +type InferPrimitive = s extends "string" | "string.url" + ? string + : s extends "number" | "number.integer" + ? number + : s extends "boolean" + ? boolean + : s extends "null" + ? null + : s extends "undefined" + ? undefined + : s extends "unknown" | "any" + ? unknown + : s extends "object" + ? object + : s extends "bigint" + ? bigint + : s extends "symbol" + ? symbol + : s extends "never" + ? never + : s extends "Date" + ? Date + : s extends "true" + ? true + : s extends "false" + ? false + : never; + +type Merge = left extends object + ? right extends object + ? Omit & right + : never + : never; + +type InferUtility = s extends `Record<${string},${infer value}>` + ? Record> + : s extends `Array<${infer element}>` + ? InferString[] + : s extends `Partial<${infer value}>` + ? Partial> + : s extends `Required<${infer value}>` + ? Required> + : s extends `Pick<${infer value},${infer keys}>` + ? Pick, Extract, keyof InferString>> + : s extends `Omit<${infer value},${infer keys}>` + ? Omit, Extract, keyof InferString>> + : s extends `Merge<${infer left},${infer right}>` + ? Merge, InferString> + : never; + +type InferMember = + Trim extends infer s extends string + ? s extends `(${infer inner})` + ? InferString + : s extends `${infer element}[]` + ? InferMember[] + : s extends `'${infer literal}'` | `"${infer literal}"` + ? literal + : s extends `d'${string}'` | `d"${string}"` + ? Date + : s extends `\`${string}\`` + ? string + : s extends `/${string}/${string}` | `/${string}/` + ? string + : s extends `${infer literal extends number}` + ? literal + : InferPrimitive extends infer primitive + ? [primitive] extends [never] + ? InferUtility extends infer utility + ? [utility] extends [never] + ? s extends `string.${string}` + ? string + : s extends `${string}Date${string}` + ? Date + : s extends `${string}string${string}` + ? string + : s extends `${string}number${string}` + ? number + : unknown + : utility + : unknown + : primitive + : unknown + : unknown; + +/** Split unions without distributing over the accumulated members. */ +type InferUnion = s extends `${infer head}|${infer tail}` + ? InferUnion> + : result | InferMember; + +type HasInlineDefault = s extends `${string}=${string}` + ? s extends `${string}<${string}` | `${string}>${string}` + ? false + : true + : false; + +type WithoutInlineDefault = + HasInlineDefault extends true ? (s extends `${infer base}=${string}` ? Trim : s) : s; + +type InferStringOutput = s extends + | "string.numeric.parse" + | "string.integer.parse" + | "parse.number" + | "parse.integer" + ? number + : s extends "string.date.parse" | "string.date.iso.parse" | "string.date.epoch.parse" | "parse.date" + ? Date + : s extends "string.url.parse" | "parse.url" + ? URL + : s extends "string.json.parse" | "parse.json" + ? unknown + : s extends "parse.boolean" + ? boolean + : s extends "parse.bigint" + ? bigint + : InferUnion; + +/** String-DSL output inference. */ +export type InferString = + WithoutInlineDefault> extends infer trimmed extends string + ? trimmed extends `${infer base}?` + ? InferString + : trimmed extends `(${infer inner})[]` + ? InferUnion[] + : InferStringOutput + : unknown; + +/** String-DSL input inference, preserving the source side of morph keywords. */ +export type InferStringIn = + WithoutInlineDefault> extends infer trimmed extends string + ? trimmed extends `${string}.parse` | `parse.${string}` + ? string + : InferString + : unknown; + +type HasDefault = def extends string + ? HasInlineDefault + : def extends readonly [unknown, "=", unknown] + ? true + : def extends { readonly hasDefault: true } + ? true + : false; + +type DefinitionKeys = Exclude; + +type IsOptionalProp = key extends `${string}?` + ? true + : def extends string + ? Trim extends `${string}?` + ? true + : false + : def extends readonly [unknown, "?"] + ? true + : false; + +type PropName = key extends `${infer name}?` ? name : key; +type UnwrapProperty = def extends readonly [infer value, "?" | "=", ...unknown[]] ? value : def; +type Simplify = { [key in keyof t]: t[key] }; + +type OutputRequired = { + -readonly [key in DefinitionKeys as IsOptionalProp extends true + ? HasDefault extends true + ? PropName + : never + : PropName]-?: InferDef>; +}; + +type OutputOptional = { + -readonly [key in DefinitionKeys as IsOptionalProp extends true + ? HasDefault extends true + ? never + : PropName + : never]?: InferDef>; +}; + +type InputRequired = { + -readonly [key in DefinitionKeys as IsOptionalProp extends true + ? never + : HasDefault extends true + ? never + : PropName]-?: InferDefIn>; +}; + +type InputOptional = { + -readonly [key in DefinitionKeys as IsOptionalProp extends true + ? PropName + : HasDefault extends true + ? PropName + : never]?: InferDefIn>; +}; + +type OutputSpread = "..." extends keyof def ? InferDef : unknown; +type InputSpread = "..." extends keyof def ? InferDefIn : unknown; +type OutputProperties = Simplify & OutputOptional & OutputSpread>; +type InputProperties = Simplify & InputOptional & InputSpread>; + +type InferObject = "[string]" extends keyof def + ? [DefinitionKeys] extends [never] + ? Record> + : OutputProperties & Record> + : OutputProperties; + +type InferObjectIn = "[string]" extends keyof def + ? [DefinitionKeys] extends [never] + ? Record> + : InputProperties & Record> + : InputProperties; + +/** Object-literal inference used by fluent composition overloads. */ +export type InferObjectDef = InferObject; + +type InferLiteralDef = def extends string + ? InferString + : def extends object + ? InferObjectLiteral + : unknown; + +type LiteralRequired = { + -readonly [key in DefinitionKeys as IsOptionalProp extends true + ? HasDefault extends true + ? PropName + : never + : PropName]-?: InferLiteralDef>; +}; + +type LiteralOptional = { + -readonly [key in DefinitionKeys as IsOptionalProp extends true + ? HasDefault extends true + ? never + : PropName + : never]?: InferLiteralDef>; +}; + +/** Object-literal-only inference that does not inspect embedded schema internals. */ +export type InferObjectLiteral = Simplify & LiteralOptional>; + +type InstanceOf = ctor extends abstract new (...args: never[]) => infer instance ? instance : never; +type SpreadOutput = InferDef extends readonly (infer element)[] ? element[] : never[]; +type SpreadInput = InferDefIn extends readonly (infer element)[] ? element[] : never[]; + +type InferTupleOutput = defs extends readonly [] + ? result + : defs extends readonly ["...", infer spread, ...infer rest] + ? [...result, ...SpreadOutput, ...InferTupleOutput] + : defs extends readonly [infer head, ...infer rest] + ? head extends readonly [infer value, "?"] + ? InferTupleOutput?]> + : head extends readonly [infer value, "=", unknown] + ? InferTupleOutput]> + : InferTupleOutput]> + : result; + +type InferTupleInput = defs extends readonly [] + ? result + : defs extends readonly ["...", infer spread, ...infer rest] + ? [...result, ...SpreadInput, ...InferTupleInput] + : defs extends readonly [infer head, ...infer rest] + ? head extends readonly [infer value, "?" | "=", ...unknown[]] + ? InferTupleInput?]> + : InferTupleInput]> + : result; + +/** Infer the validated output type produced by a definition. */ +export type InferDef = def extends { readonly infer: infer output } + ? output + : def extends string + ? InferString + : def extends RegExp + ? string + : def extends readonly [infer element, "[]"] + ? InferDef[] + : def extends readonly [infer left, "|", infer right] + ? InferDef | InferDef + : def extends readonly [infer left, "&", infer right] + ? InferDef & InferDef + : def extends readonly [unknown, "=>", (...args: never[]) => infer output] + ? output + : def extends readonly [unknown, "|>", infer output] + ? InferDef + : def extends readonly [infer base, ":", unknown] | readonly [infer base, "@", unknown] + ? InferDef + : def extends readonly ["keyof", infer base] + ? keyof InferDef + : def extends readonly ["instanceof", ...infer constructors] + ? InstanceOf + : def extends readonly ["===", ...infer values] + ? values[number] + : def extends readonly unknown[] + ? InferTupleOutput + : def extends object + ? InferObject + : unknown; + +/** Infer values accepted before defaults and morphs are applied. */ +export type InferDefIn = def extends { readonly inferIn: infer input } + ? input + : def extends string + ? InferStringIn + : def extends RegExp + ? string + : def extends readonly [infer element, "[]"] + ? InferDefIn[] + : def extends readonly [infer left, "|", infer right] + ? InferDefIn | InferDefIn + : def extends readonly [infer left, "&", infer right] + ? InferDefIn & InferDefIn + : def extends readonly [infer input, "=>", unknown] | readonly [infer input, "|>", unknown] + ? InferDefIn + : def extends readonly [infer base, ":", unknown] | readonly [infer base, "@", unknown] + ? InferDefIn + : def extends readonly ["keyof", infer base] + ? keyof InferDefIn + : def extends readonly ["instanceof", ...infer constructors] + ? InstanceOf + : def extends readonly ["===", ...infer values] + ? values[number] + : def extends readonly unknown[] + ? InferTupleInput + : def extends object + ? InferObjectIn + : unknown; diff --git a/packages/omptype/src/interp.ts b/packages/omptype/src/interp.ts new file mode 100644 index 000000000..94a0629e5 --- /dev/null +++ b/packages/omptype/src/interp.ts @@ -0,0 +1,426 @@ +/** + * Tree-walking validator used for a schema's first few calls and as the + * targeted fallback for recursive or predicate-only JIT subtrees. + * + * Semantics must stay in lockstep with `compile.ts`: + * - success returns the output value; the input is returned as-is unless the + * schema morphs (defaults, `"+": "delete"`, embedded stepped schemas), in + * which case a fresh object/array is produced and the input is untouched + * - failure returns an `OmpErrors` with a single fast-fail entry + */ +import { MISSING, OmpErrors } from "./errors"; +import { expectedOf, hasMorph, type IR } from "./ir"; + +const own = Object.prototype.hasOwnProperty; + +/** Validate `value` against `ir`; returns output value or `OmpErrors`. */ +export function walk(ir: IR, value: unknown): unknown { + const path: PropertyKey[] = []; + const out = visit(ir, value, path); + return out; +} + +function fail(path: PropertyKey[], expected: string, data: unknown): OmpErrors { + const storedPath = path.length === 0 ? undefined : path.length === 1 ? path[0] : [...path]; + return new OmpErrors(storedPath, expected, data); +} + +/** Pure predicate used for union-member scanning (no morphs, no errors). */ +function checks(ir: IR, v: unknown): boolean { + switch (ir.k) { + case "unknown": + return true; + case "null": + return v === null; + case "undefined": + return v === undefined; + case "boolean": + return typeof v === "boolean"; + case "bigint": + return typeof v === "bigint"; + case "symbol": + return typeof v === "symbol"; + case "never": + return false; + case "anyobject": + return typeof v === "object" && v !== null; + case "string": + return ( + typeof v === "string" && + (ir.min === undefined || v.length >= ir.min) && + (ir.max === undefined || v.length <= ir.max) && + (!ir.url || URL.canParse(v)) + ); + case "number": + if (typeof v !== "number") return false; + return ( + (ir.int ? Number.isInteger(v) : Number.isFinite(v)) && + (ir.divisor === undefined || v % ir.divisor === 0) && + (ir.min === undefined || (ir.xmin ? v > ir.min : v >= ir.min)) && + (ir.max === undefined || (ir.xmax ? v < ir.max : v <= ir.max)) + ); + case "lit": + return ir.v instanceof Date ? v instanceof Date && v.valueOf() === ir.v.valueOf() : v === ir.v; + case "union": + return ir.members.some(m => checks(m, v)); + case "intersection": + return ir.members.every(member => checks(member, v)); + case "array": { + if (!Array.isArray(v)) return false; + if (ir.min !== undefined && v.length < ir.min) return false; + if (ir.max !== undefined && v.length > ir.max) return false; + for (const el of v) if (!checks(ir.el, el)) return false; + return true; + } + case "tuple": { + if (!Array.isArray(v)) return false; + let required = ir.postfix.length; + for (const item of ir.prefix) if (!item.opt && !item.hasDefault) required++; + if (v.length < required) return false; + if (ir.variadic === undefined && v.length > ir.prefix.length + ir.postfix.length) return false; + const postfixStart = v.length - ir.postfix.length; + const prefixCount = Math.min(ir.prefix.length, postfixStart); + for (let index = 0; index < prefixCount; index++) { + if (!checks(ir.prefix[index].val, v[index])) return false; + } + for (let index = prefixCount; index < ir.prefix.length; index++) { + const item = ir.prefix[index]; + if (!item.opt && !item.hasDefault) return false; + } + if (ir.variadic !== undefined) { + for (let index = prefixCount; index < postfixStart; index++) { + if (!checks(ir.variadic, v[index])) return false; + } + } + for (let index = 0; index < ir.postfix.length; index++) { + if (!checks(ir.postfix[index], v[postfixStart + index])) return false; + } + return true; + } + case "object": { + if (typeof v !== "object" || v === null || Array.isArray(v)) return false; + const rec = v as Record; + for (const p of ir.props) { + const present = p.key in rec; + if (!present) { + if (!p.opt && !p.hasDefault) return false; + continue; + } + if (!checks(p.val, rec[p.key])) return false; + } + if (ir.index) { + for (const key in rec) { + if (own.call(rec, key) && !checks(ir.index, rec[key])) return false; + } + } else if (ir.extras === "reject") { + for (const key in rec) { + if (!own.call(rec, key)) continue; + let declared = false; + for (const p of ir.props) { + if (p.key === key) { + declared = true; + break; + } + } + if (!declared) return false; + } + } + return true; + } + case "instance": + return v instanceof ir.ctor; + case "refine": + if (!checks(ir.base, v)) return false; + try { + return ir.pred(v); + } catch { + return false; + } + case "alias": + return checks(ir.resolve(), v); + case "morph": + return checks(ir.input, v); + case "sub": + return !(ir.schema.run(v) instanceof OmpErrors); + } +} + +function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown { + switch (ir.k) { + case "alias": + return visit(ir.resolve(), v, path); + case "refine": { + const base = visit(ir.base, v, path); + if (base instanceof OmpErrors) return base; + try { + return ir.pred(base) ? base : fail(path, ir.expected, base); + } catch { + return fail(path, ir.expected, base); + } + } + case "morph": { + const input = visit(ir.input, v, path); + if (input instanceof OmpErrors) return input; + const context = { + error: (expected: string, data: unknown = input) => fail(path, expected, data), + reject: (problem: string, data: unknown = input) => fail(path, problem, data), + }; + const output = ir.fn(input, context); + if (output instanceof OmpErrors) return output; + return ir.out === undefined ? output : visit(ir.out, output, path); + } + case "intersection": { + let output = v; + for (const member of ir.members) { + output = visit(member, output, path); + if (output instanceof OmpErrors) return output; + } + return output; + } + case "sub": { + const out = ir.schema.run(v); + if (out instanceof OmpErrors) { + return path.length === 0 ? out : prefixAll(out, path); + } + return out; + } + case "union": { + // fast path: any pure member matching returns the input unchanged + for (const m of ir.members) { + if (m.k !== "sub" && checks(m, v)) { + if (hasMorph(m)) break; + return v; + } + } + for (const m of ir.members) { + if (m.k === "sub" || hasMorph(m)) { + const out = visit(m, v, path); + if (!(out instanceof OmpErrors)) return out; + } + } + return ir.members.some(canRefineUnionFailure) ? unionFail(ir, v, path) : fail(path, expectedOf(ir), v); + } + case "array": { + if (!Array.isArray(v)) return fail(path, "an array", v); + if (ir.min !== undefined && v.length < ir.min) return fail(path, `at least length ${ir.min}`, v); + if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v); + if (!hasMorph(ir.el)) { + for (let i = 0; i < v.length; i++) { + if (!checks(ir.el, v[i])) { + path.push(i); + const err = visit(ir.el, v[i], path); + path.pop(); + return err instanceof OmpErrors ? err : fail([...path, i], expectedOf(ir.el), v[i]); + } + } + return v; + } + const out = new Array(v.length); + for (let i = 0; i < v.length; i++) { + path.push(i); + const el = visit(ir.el, v[i], path); + path.pop(); + if (el instanceof OmpErrors) return el; + out[i] = el; + } + return out; + } + case "tuple": { + if (!Array.isArray(v)) return fail(path, "an array", v); + let required = ir.postfix.length; + for (const item of ir.prefix) if (!item.opt && !item.hasDefault) required++; + if (v.length < required) return fail(path, `an array of at least length ${required}`, v); + const maximum = ir.prefix.length + ir.postfix.length; + if (ir.variadic === undefined && v.length > maximum) { + return fail(path, `an array of at most length ${maximum}`, v); + } + const postfixStart = v.length - ir.postfix.length; + const prefixCount = Math.min(ir.prefix.length, postfixStart); + const morph = hasMorph(ir); + const output = morph ? [...v] : v; + for (let index = 0; index < prefixCount; index++) { + path.push(index); + const item = visit(ir.prefix[index].val, v[index], path); + path.pop(); + if (item instanceof OmpErrors) return item; + if (morph) output[index] = item; + } + for (let index = prefixCount; index < ir.prefix.length; index++) { + const item = ir.prefix[index]; + if (item.hasDefault && morph) { + const payload = item.def; + output[index] = item.defFactory && typeof payload === "function" ? payload() : payload; + } else if (!item.opt) { + path.push(index); + const error = fail(path, expectedOf(item.val), MISSING); + path.pop(); + return error; + } + } + if (ir.variadic !== undefined) { + for (let index = prefixCount; index < postfixStart; index++) { + path.push(index); + const item = visit(ir.variadic, v[index], path); + path.pop(); + if (item instanceof OmpErrors) return item; + if (morph) output[index] = item; + } + } + for (let index = 0; index < ir.postfix.length; index++) { + const inputIndex = postfixStart + index; + path.push(inputIndex); + const item = visit(ir.postfix[index], v[inputIndex], path); + path.pop(); + if (item instanceof OmpErrors) return item; + if (morph) output[inputIndex] = item; + } + return output; + } + case "object": { + if (typeof v !== "object" || v === null || Array.isArray(v)) return fail(path, "an object", v); + const rec = v as Record; + const morph = hasMorph(ir); + let out: Record | undefined; + if (morph) { + if (ir.extras === "delete" && !ir.index) { + out = {}; + } else { + out = { ...rec }; + } + } + for (const p of ir.props) { + if (!(p.key in rec)) { + if (p.hasDefault && out) { + // defFactory guarantees a callable default payload + const payload = p.def; + out[p.key] = p.defFactory && typeof payload === "function" ? payload() : payload; + continue; + } + if (p.opt || p.hasDefault) continue; + path.push(p.key); + const err = fail(path, expectedOf(p.val), MISSING); + path.pop(); + return err; + } + path.push(p.key); + const res = visit(p.val, rec[p.key], path); + path.pop(); + if (res instanceof OmpErrors) return res; + if (out) out[p.key] = res; + } + if (ir.index) { + for (const key in rec) { + if (!own.call(rec, key)) continue; + path.push(key); + const res = visit(ir.index, rec[key], path); + path.pop(); + if (res instanceof OmpErrors) return res; + if (out) out[key] = res; + } + } else if (ir.extras === "reject") { + for (const key in rec) { + if (!own.call(rec, key)) continue; + let declared = false; + for (const p of ir.props) { + if (p.key === key) { + declared = true; + break; + } + } + if (!declared) { + path.push(key); + const err = fail(path, "removed (undeclared key)", rec[key]); + path.pop(); + return err; + } + } + } + return out ?? v; + } + default: + return checks(ir, v) ? v : fail(path, expectedOf(ir), v); + } +} + +function prefixAll(errs: OmpErrors, path: PropertyKey[]): OmpErrors { + for (let i = path.length - 1; i >= 0; i--) errs.prefix(path[i]); + return errs; +} + +/** True when a union failure can be replaced with a more specific nested error. */ +export function canRefineUnionFailure(member: IR): boolean { + const base = member.k === "sub" ? member.schema.ir : member; + if (member.k === "sub") { + return ( + base.k === "array" || + base.k === "object" || + base.k === "anyobject" || + base.k === "string" || + base.k === "number" + ); + } + if (base.k === "array" || base.k === "object") return true; + if (base.k === "string") return base.min !== undefined || base.max !== undefined || base.url === true; + return base.k === "number" && (base.int === true || base.min !== undefined || base.max !== undefined); +} + +/** + * Detailed failure for a union: descend into the member the value was clearly + * aimed at — unique runtime-kind match, else an object member whose literal + * discriminant property (e.g. `type: "'computer_call'"`) equals the value's — + * for a precise nested error (paths, narrow messages) instead of the coarse + * "A or B" expectation. + */ +export function unionFail(ir: IR & { k: "union" }, v: unknown, path: PropertyKey[], expected?: string): OmpErrors { + let best: IR | undefined; + for (const m of ir.members) { + const base = m.k === "sub" ? m.schema.ir : m; + if (!kindMatches(base, v)) continue; + if (best !== undefined) { + best = undefined; + break; + } + best = m; + } + if (best === undefined) best = discriminate(ir.members, v); + if (best) { + const out = visit(best, v, path); + if (out instanceof OmpErrors) return out; + } + return fail(path, expected ?? expectedOf(ir), v); +} + +/** Pick the sole object member whose literal-typed property matches the value's. */ +function discriminate(members: IR[], v: unknown): IR | undefined { + if (typeof v !== "object" || v === null || Array.isArray(v)) return undefined; + const rec = v as Record; + let match: IR | undefined; + for (const m of members) { + const base = m.k === "sub" ? m.schema.ir : m; + if (base.k !== "object") continue; + for (const p of base.props) { + if (p.val.k !== "lit" || rec[p.key] !== p.val.v) continue; + if (match !== undefined) return undefined; // ambiguous + match = m; + break; + } + } + return match; +} + +/** True when a value's runtime shape could only be aimed at this member. */ +function kindMatches(base: IR, v: unknown): boolean { + switch (base.k) { + case "array": + return Array.isArray(v); + case "object": + case "anyobject": + return typeof v === "object" && v !== null && !Array.isArray(v); + case "string": + return typeof v === "string"; + case "number": + return typeof v === "number"; + default: + return false; + } +} diff --git a/packages/omptype/src/ir.ts b/packages/omptype/src/ir.ts new file mode 100644 index 000000000..408a29d44 --- /dev/null +++ b/packages/omptype/src/ir.ts @@ -0,0 +1,1002 @@ +/** + * Schema IR and the ArkType-compatible definition parser. + * + * `parseDef` turns the definition subset this repo uses — string DSL + * (primitives, literals, unions, arrays, bounds, `number.integer`, + * `string.url`, inline `= literal` defaults), object literals (optional `?` + * keys, `"+"` undeclared-key policy, `"[string]"` index signatures), tuple + * `[def, "[]"]` arrays, and embedded `Type` instances — into a small IR tree + * consumed by the interpreter (`interp.ts`), the JIT compiler (`compile.ts`), + * and the JSON Schema emitter (`json-schema.ts`). + */ + +import { OmpErrors, OmpTypeError } from "./errors"; +import { keywordIR, patternIR, templateIR } from "./keywords"; + +/** Brand carried by `Type` instances so the parser can embed them in defs. */ +export const IR_BRAND: unique symbol = Symbol("omptype.schema"); + +const kMorph: unique symbol = Symbol("omptype.hasMorph"); + +interface IRAnalysis { + [kMorph]?: boolean; +} + +/** + * The parser-facing surface of an embedded `Type` instance. + * `type.ts` implements this on every schema it creates. + */ +export interface EmbeddableSchema { + [IR_BRAND]: true; + /** Structural IR of the schema (base type when runtime steps exist). */ + ir: IR; + /** True when the schema carries `.pipe()`/`.narrow()` steps. */ + hasSteps: boolean; + /** `.default()` payload; a function is a factory invoked per fill. */ + defaultValue?: unknown; + hasDefault: boolean; + /** `.describe()` annotation, emitted into JSON Schema. */ + description?: string; + /** Full validate+morph pipeline (identical to calling the schema). */ + run(value: unknown): unknown; +} + +/** Policy for undeclared object keys. */ +export type Extras = "keep" | "reject" | "delete"; + +/** Constructor accepted by `type.instanceOf` and tuple `instanceof` expressions. */ +export type Constructor = abstract new (...args: never[]) => object; + +/** Context available to in-definition morph callbacks. */ +export interface MorphContext { + /** Return a validation error at the current path. */ + error(expectation: string): OmpErrors; + /** Alias of `error` matching ArkType's rejection vocabulary. */ + reject(expectation: string): OmpErrors; +} + +/** One fixed tuple position, optionally absent or defaulted. */ +export interface TupleItemIR { + val: IR; + opt: boolean; + def?: unknown; + defFactory?: boolean; + hasDefault?: boolean; +} + +/** Fixed, optional, variadic, and postfix tuple sequence. */ +export interface TupleIR { + k: "tuple"; + prefix: TupleItemIR[]; + variadic?: IR; + postfix: IR[]; + desc?: string; +} + +export type IR = IRAnalysis & + ( + | { k: "unknown"; desc?: string } + | { k: "null"; desc?: string } + | { k: "undefined"; desc?: string } + | { k: "boolean"; desc?: string } + | { k: "bigint"; desc?: string } + | { k: "symbol"; desc?: string } + | { k: "never"; desc?: string } + /** Any non-null object (the bare `object` keyword). */ + | { k: "anyobject"; desc?: string } + | { k: "string"; min?: number; max?: number; url?: boolean; desc?: string } + | { + k: "number"; + min?: number; + max?: number; + xmin?: boolean; + xmax?: boolean; + int?: boolean; + divisor?: number; + desc?: string; + } + | { k: "lit"; v: unknown; desc?: string } + | { k: "union"; members: IR[]; desc?: string } + | { k: "intersection"; members: IR[]; desc?: string } + | { k: "array"; el: IR; min?: number; max?: number; desc?: string } + | TupleIR + | { k: "object"; props: PropIR[]; index?: IR; extras: Extras; desc?: string } + | { + k: "refine"; + base: IR; + pred: (value: unknown) => boolean; + expected: string; + json?: Record; + desc?: string; + } + | { + k: "morph"; + input: IR; + fn: (value: unknown, context: MorphContext) => unknown; + out?: IR; + desc?: string; + } + | { k: "instance"; ctor: Constructor; expected: string; desc?: string } + | { k: "alias"; name: string; resolve: () => IR; desc?: string } + /** Embedded schema with runtime steps; validated by calling `run`. */ + | { k: "sub"; schema: EmbeddableSchema; desc?: string } + ); + +export interface PropIR { + key: string; + opt: boolean; + val: IR; + /** Default payload (value, or factory when `defFactory`); missing key is filled. */ + def?: unknown; + defFactory?: boolean; + hasDefault?: boolean; +} + +/** Definition input accepted by `type()` and object property values. */ +export type Def = string | RegExp | Date | EmbeddableSchema | readonly unknown[] | { readonly [k: string]: unknown }; + +// ── tokenizer ──────────────────────────────────────────────────────────────── + +type Tok = + | { t: "id"; v: string } + | { t: "num"; v: number } + | { t: "date"; v: Date } + | { t: "str"; v: string } + | { t: "op"; v: string }; + +const SIMPLE_OPS = "|()[]=?%"; + +function tokenize(src: string): Tok[] { + const toks: Tok[] = []; + let i = 0; + const n = src.length; + while (i < n) { + const c = src[i]; + if (c === " " || c === "\t" || c === "\n" || c === "\r") { + i++; + continue; + } + if (c === "'" || c === '"') { + let j = i + 1; + while (j < n && src[j] !== c) j++; + if (j >= n) throw new OmpTypeError(`unterminated string literal in "${src}"`); + toks.push({ t: "str", v: src.slice(i + 1, j) }); + i = j + 1; + continue; + } + if (c === "d" && (src[i + 1] === "'" || src[i + 1] === '"')) { + const quote = src[i + 1]; + const end = src.indexOf(quote, i + 2); + if (end < 0) throw new OmpTypeError(`unterminated date literal in "${src}"`); + const value = new Date(src.slice(i + 2, end)); + if (Number.isNaN(value.valueOf())) throw new OmpTypeError(`invalid date literal in "${src}"`); + toks.push({ t: "date", v: value }); + i = end + 1; + continue; + } + if ((c >= "0" && c <= "9") || (c === "-" && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9")) { + let j = i + 1; + while (j < n && ((src[j] >= "0" && src[j] <= "9") || src[j] === "." || src[j] === "e" || src[j] === "+")) j++; + toks.push({ t: "num", v: Number(src.slice(i, j)) }); + i = j; + continue; + } + if (/[a-zA-Z_$]/.test(c)) { + let j = i + 1; + while (j < n && /[\w.$]/.test(src[j])) j++; + toks.push({ t: "id", v: src.slice(i, j) }); + i = j; + continue; + } + if (c === "<" || c === ">") { + if (src[i + 1] === "=") { + toks.push({ t: "op", v: `${c}=` }); + i += 2; + } else { + toks.push({ t: "op", v: c }); + i++; + } + continue; + } + if (SIMPLE_OPS.includes(c)) { + toks.push({ t: "op", v: c }); + i++; + continue; + } + throw new OmpTypeError(`unexpected character '${c}' in "${src}"`); + } + return toks; +} + +// ── string-definition parser ───────────────────────────────────────────────── + +const CMP: Record = { "<": true, "<=": true, ">": true, ">=": true }; + +const KEYWORDS: Record IR> = { + number: () => ({ k: "number" }), + "number.integer": () => ({ k: "number", int: true }), + boolean: () => ({ k: "boolean" }), + bigint: () => ({ k: "bigint" }), + symbol: () => ({ k: "symbol" }), + never: () => ({ k: "never" }), + null: () => ({ k: "null" }), + undefined: () => ({ k: "undefined" }), + unknown: () => ({ k: "unknown" }), + any: () => ({ k: "unknown" }), + object: () => ({ k: "anyobject" }), + Date: () => ({ k: "instance", ctor: Date, expected: "a Date" }), + true: () => ({ k: "lit", v: true }), + false: () => ({ k: "lit", v: false }), +}; + +/** Resolve a named scope alias to its lazy IR reference. */ +export type AliasResolver = (name: string) => IR | undefined; + +interface ParsedTop { + ir: IR; + def?: unknown; + hasDefault: boolean; + /** Trailing `?` marker — only legal on object property values. */ + optional: boolean; +} + +class StrParser { + #toks: Tok[]; + #pos = 0; + #src: string; + #resolve: AliasResolver | undefined; + + constructor(src: string, resolve?: AliasResolver) { + this.#src = src; + this.#resolve = resolve; + this.#toks = tokenize(src); + } + + #peek(offset = 0): Tok | undefined { + return this.#toks[this.#pos + offset]; + } + + #next(): Tok { + const t = this.#toks[this.#pos++]; + if (!t) throw new OmpTypeError(`unexpected end of definition "${this.#src}"`); + return t; + } + + #eatOp(v: string): boolean { + const t = this.#peek(); + if (t?.t === "op" && t.v === v) { + this.#pos++; + return true; + } + return false; + } + + /** Full definition with optional trailing `= literal` default and/or `?` optional marker. */ + parseTop(): ParsedTop { + const ir = this.parseUnion(); + let def: unknown; + let hasDefault = false; + if (this.#eatOp("=")) { + const t = this.#next(); + if (t.t === "num" || t.t === "str") def = t.v; + else if (t.t === "id" && (t.v === "true" || t.v === "false")) def = t.v === "true"; + else if (t.t === "id" && t.v === "null") def = null; + else throw new OmpTypeError(`unsupported default literal in "${this.#src}"`); + hasDefault = true; + } + const optional = this.#eatOp("?"); + this.#expectEnd(); + return { ir, def, hasDefault, optional }; + } + + #expectEnd(): void { + if (this.#pos < this.#toks.length) { + throw new OmpTypeError(`trailing tokens in definition "${this.#src}"`); + } + } + + parseUnion(): IR { + const first = this.parseBounded(); + if (!this.#eatOp("|")) return first; + const members = [first, this.parseBounded()]; + while (this.#eatOp("|")) members.push(this.parseBounded()); + return { k: "union", members }; + } + + /** + * `NUM CMP base (CMP NUM)?` or `base (CMP NUM)?`, with `[]*` postfix on the + * base AND after a trailing bound — `string>0[]` is an array of bounded + * strings, matching ArkType precedence (bounds bind tighter than `[]`). + */ + parseBounded(): IR { + const t = this.#peek(); + const t1 = this.#peek(1); + if ((t?.t === "num" || t?.t === "date") && t1?.t === "op" && CMP[t1.v]) { + const lo = t.v; + this.#pos += 2; + let node = this.#eatDivisor(this.parsePostfix()); + node = applyBound(node, flip(t1.v), lo, this.#src); + const t2 = this.#peek(); + if (t2?.t === "op" && CMP[t2.v]) { + this.#pos++; + const hi = this.#next(); + if (hi.t !== "num" && hi.t !== "date") { + throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`); + } + node = applyBound(node, t2.v, hi.v, this.#src); + } + return this.#eatArraySuffixes(node); + } + let node = this.#eatDivisor(this.parsePostfix()); + const t2 = this.#peek(); + if (t2?.t === "op" && CMP[t2.v]) { + this.#pos++; + const limit = this.#next(); + if (limit.t !== "num" && limit.t !== "date") { + throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`); + } + node = applyBound(node, t2.v, limit.v, this.#src); + node = this.#eatArraySuffixes(node); + } + return node; + } + + #eatDivisor(node: IR): IR { + if (!this.#eatOp("%")) return node; + const divisor = this.#next(); + if (divisor.t !== "num") throw new OmpTypeError(`expected number after % in "${this.#src}"`); + if (node.k !== "number") throw new OmpTypeError(`% requires number in "${this.#src}"`); + if (!Number.isFinite(divisor.v) || divisor.v === 0) + throw new OmpTypeError(`divisor must be non-zero in "${this.#src}"`); + node.divisor = divisor.v; + return node; + } + + /** Wrap `node` in array IR for each `[]` pair at the cursor. */ + #eatArraySuffixes(node: IR): IR { + for (;;) { + const t = this.#peek(); + if (!(t?.t === "op" && t.v === "[")) return node; + this.#pos++; + if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`); + node = { k: "array", el: node }; + } + } + + parsePostfix(): IR { + let node = this.parsePrimary(); + for (;;) { + const t = this.#peek(); + if (!(t?.t === "op" && t.v === "[")) break; + this.#pos++; + if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`); + node = { k: "array", el: node }; + } + return node; + } + + parsePrimary(): IR { + const t = this.#next(); + if (t.t === "op" && t.v === "(") { + const inner = this.parseUnion(); + if (!this.#eatOp(")")) throw new OmpTypeError(`expected ')' in "${this.#src}"`); + return inner; + } + if (t.t === "str" || t.t === "num" || t.t === "date") return { k: "lit", v: t.v }; + if (t.t === "id") { + const make = KEYWORDS[t.v]; + const keyword = make?.() ?? keywordIR(t.v) ?? this.#resolve?.(t.v); + if (!keyword) throw new OmpTypeError(`unknown keyword "${t.v}" in "${this.#src}"`); + return keyword; + } + throw new OmpTypeError(`unexpected token in "${this.#src}"`); + } +} + +const STRING_DEF_CACHE_MAX = 1_024; +const stringDefCache = new Map(); + +/** Fast path for the literal unions pervasive in command schemas. */ +function parseLiteralUnion(src: string): IR | undefined { + const members: IR[] = []; + let index = 0; + while (index < src.length && /\s/.test(src[index])) index++; + for (;;) { + const quote = src[index]; + if (quote !== "'" && quote !== '"') return undefined; + const end = src.indexOf(quote, index + 1); + if (end < 0) return undefined; + members.push({ k: "lit", v: src.slice(index + 1, end) }); + index = end + 1; + while (index < src.length && /\s/.test(src[index])) index++; + if (index === src.length) { + return members.length === 1 ? members[0] : { k: "union", members }; + } + if (src[index] !== "|") return undefined; + index++; + while (index < src.length && /\s/.test(src[index])) index++; + } +} + +function genericArguments(src: string): { name: string; args: string[] } | undefined { + const open = src.indexOf("<"); + if (open < 1 || !src.endsWith(">")) return undefined; + const name = src.slice(0, open).trim(); + const body = src.slice(open + 1, -1); + const args: string[] = []; + let depth = 0; + let quote = ""; + let start = 0; + for (let index = 0; index < body.length; index++) { + const char = body[index]; + if (quote !== "") { + if (char === quote && body[index - 1] !== "\\") quote = ""; + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "<" || char === "(" || char === "[") { + depth++; + } else if (char === ">" || char === ")" || char === "]") { + depth--; + } else if (char === "," && depth === 0) { + args.push(body.slice(start, index).trim()); + start = index + 1; + } + } + args.push(body.slice(start).trim()); + return { name, args }; +} + +function genericKeys(ir: IR): Set { + const keys = new Set(); + const visit = (node: IR): void => { + if (node.k === "lit" && typeof node.v === "string") keys.add(node.v); + else if (node.k === "union") for (const member of node.members) visit(member); + }; + visit(ir); + return keys; +} + +function resolveStructuralIR(ir: IR): IR { + const seen = new Set(); + while (ir.k === "alias") { + if (seen.has(ir)) throw new OmpTypeError(`cannot structurally transform recursive alias "${ir.name}"`); + seen.add(ir); + ir = ir.resolve(); + } + return ir; +} + +function mergeObjectIR(left: IR, right: IR): IR { + left = resolveStructuralIR(left); + right = resolveStructuralIR(right); + if (left.k !== "object" || right.k !== "object") { + throw new OmpTypeError("Merge requires object arguments"); + } + const props = [...left.props]; + for (const prop of right.props) { + const index = props.findIndex(candidate => candidate.key === prop.key); + if (index < 0) props.push(prop); + else props[index] = prop; + } + return { + k: "object", + props, + index: right.index ?? left.index, + extras: right.extras === "keep" ? left.extras : right.extras, + }; +} + +function parseGeneric(src: string, resolve?: AliasResolver): IR | undefined { + const generic = genericArguments(src); + if (generic === undefined) return undefined; + if (generic.name === "Record" && generic.args.length === 2) { + return { k: "object", props: [], index: parseDef(generic.args[1], resolve), extras: "keep" }; + } + if ((generic.name === "Partial" || generic.name === "Required") && generic.args.length === 1) { + const object = resolveStructuralIR(parseDef(generic.args[0], resolve)); + if (object.k !== "object") throw new OmpTypeError(`${generic.name} requires an object`); + const optional = generic.name === "Partial"; + return { ...object, props: object.props.map(prop => ({ ...prop, opt: optional })) }; + } + if ((generic.name === "Pick" || generic.name === "Omit") && generic.args.length === 2) { + const object = resolveStructuralIR(parseDef(generic.args[0], resolve)); + if (object.k !== "object") throw new OmpTypeError(`${generic.name} requires an object`); + const keys = genericKeys(parseDef(generic.args[1], resolve)); + const pick = generic.name === "Pick"; + return { ...object, props: object.props.filter(prop => keys.has(prop.key) === pick) }; + } + if (generic.name === "Merge" && generic.args.length === 2) { + return mergeObjectIR(parseDef(generic.args[0], resolve), parseDef(generic.args[1], resolve)); + } + return undefined; +} + +function dateLiteral(source: string): Date { + const value = new Date(source); + if (!Number.isFinite(value.valueOf())) throw new OmpTypeError(`invalid Date literal "${source}"`); + return value; +} + +function parseDateExpression(src: string): IR | undefined { + const literal = src.match(/^d(['"])(.*)\1$/); + if (literal) return { k: "lit", v: dateLiteral(literal[2]) }; + const forward = src.match(/^Date\s*(<=|<|>=|>)\s*d(['"])(.*)\2$/); + const reverse = src.match(/^d(['"])(.*)\1\s*(<=|<|>=|>)\s*Date$/); + if (!forward && !reverse) return undefined; + const bound = dateLiteral(forward ? forward[3] : (reverse?.[2] ?? "")); + const operator = forward + ? forward[1] + : reverse?.[3] === "<=" + ? ">=" + : reverse?.[3] === "<" + ? ">" + : reverse?.[3] === ">=" + ? "<=" + : "<"; + const timestamp = bound.valueOf(); + const relation = + operator === ">=" + ? "at or after" + : operator === ">" + ? "later than" + : operator === "<=" + ? "at or before" + : "earlier than"; + return { + k: "refine", + base: { k: "instance", ctor: Date, expected: "a Date" }, + pred: value => { + if (!(value instanceof Date)) return false; + const actual = value.valueOf(); + return operator === ">=" + ? actual >= timestamp + : operator === ">" + ? actual > timestamp + : operator === "<=" + ? actual <= timestamp + : actual < timestamp; + }, + expected: `a Date ${relation} ${bound.toISOString()}`, + json: operator === ">=" || operator === ">" ? { minimum: bound.toISOString() } : { maximum: bound.toISOString() }, + }; +} + +/** Parse recurring global DSL fragments once; scoped aliases bypass the cache. */ +function parseStringDef(src: string, resolve?: AliasResolver): ParsedTop { + if (resolve === undefined) { + const cached = stringDefCache.get(src); + if (cached) return cached; + } + let ir = parseDateExpression(src) ?? parseLiteralUnion(src) ?? parseGeneric(src, resolve); + if (ir === undefined && src.startsWith("`") && src.endsWith("`")) { + ir = templateIR(src.slice(1, -1)); + } else if (ir === undefined && src.startsWith("/") && src.lastIndexOf("/") > 0) { + const end = src.lastIndexOf("/"); + try { + ir = patternIR(new RegExp(src.slice(1, end), src.slice(end + 1))); + } catch { + throw new OmpTypeError(`invalid regular expression "${src}"`); + } + } + const parsed: ParsedTop = + ir === undefined ? new StrParser(src, resolve).parseTop() : { ir, hasDefault: false, optional: false }; + if (resolve === undefined && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed); + return parsed; +} + +function flip(op: string): string { + switch (op) { + case "<": + return ">"; + case "<=": + return ">="; + case ">": + return "<"; + default: + return "<="; + } +} + +/** Apply `node CMP value` — numeric/string/array ranges or Date bounds. */ +function applyBound(node: IR, op: string, value: number | Date, src: string): IR { + if (value instanceof Date) { + if (!acceptsDate(node)) throw new OmpTypeError(`date bound requires Date in "${src}"`); + const limit = value.valueOf(); + const relation = + op === ">=" ? "on or after" : op === ">" ? "later than" : op === "<=" ? "on or before" : "earlier than"; + return { + k: "refine", + base: node, + pred: input => { + if (!(input instanceof Date)) return false; + const time = input.valueOf(); + return op === ">=" ? time >= limit : op === ">" ? time > limit : op === "<=" ? time <= limit : time < limit; + }, + expected: `a Date ${relation} ${value.toISOString()}`, + }; + } + if (node.k === "number") { + switch (op) { + case ">=": + node.min = value; + node.xmin = false; + break; + case ">": + node.min = value; + node.xmin = true; + break; + case "<=": + node.max = value; + node.xmax = false; + break; + case "<": + node.max = value; + node.xmax = true; + break; + } + return node; + } + if (node.k === "string" || node.k === "array") { + switch (op) { + case ">=": + node.min = value; + break; + case ">": + node.min = value + 1; + break; + case "<=": + node.max = value; + break; + case "<": + node.max = value - 1; + break; + } + return node; + } + throw new OmpTypeError(`cannot bound ${node.k} in "${src}"`); +} + +function acceptsDate(node: IR): boolean { + return (node.k === "instance" && node.ctor === Date) || (node.k === "refine" && acceptsDate(node.base)); +} + +// ── definition parser ──────────────────────────────────────────────────────── + +function isEmbedded(def: unknown): def is EmbeddableSchema { + return (typeof def === "function" || (typeof def === "object" && def !== null)) && IR_BRAND in def; +} + +/** Embed a schema value: inline pure structure, keep `sub` nodes for stepped schemas. */ +export function embed(schema: EmbeddableSchema): IR { + if (schema.hasSteps) return { k: "sub", schema, desc: schema.description }; + if (schema.description !== undefined && schema.ir.desc === undefined) { + return { ...schema.ir, desc: schema.description }; + } + return schema.ir; +} + +function isCallback(value: unknown): value is (input: unknown, context: MorphContext) => unknown { + return typeof value === "function"; +} + +function isConstructor(value: unknown): value is Constructor { + return typeof value === "function" && value.prototype !== undefined; +} + +function parseTupleItem(def: unknown, resolve?: AliasResolver): TupleItemIR { + if (Array.isArray(def) && def.length === 2 && def[1] === "?") { + return { val: parseDef(def[0], resolve), opt: true }; + } + if (Array.isArray(def) && def.length === 3 && def[1] === "=") { + return { + val: parseDef(def[0], resolve), + opt: true, + def: def[2], + defFactory: typeof def[2] === "function", + hasDefault: true, + }; + } + return { val: parseDef(def, resolve), opt: false }; +} + +function parseTuple(def: readonly unknown[], resolve?: AliasResolver): TupleIR { + const prefix: TupleItemIR[] = []; + const postfix: IR[] = []; + let variadic: IR | undefined; + let optionalSeen = false; + for (let index = 0; index < def.length; index++) { + if (def[index] === "...") { + if (variadic !== undefined || index + 1 >= def.length) { + throw new OmpTypeError("a tuple may have one spread followed by an array definition"); + } + const spread = parseDef(def[++index], resolve); + if (spread.k !== "array") throw new OmpTypeError("tuple spread element must be an array"); + variadic = spread.el; + continue; + } + if (variadic !== undefined) { + const item = parseTupleItem(def[index], resolve); + if (item.opt || item.hasDefault) { + throw new OmpTypeError("optional tuple elements cannot follow a variadic element"); + } + postfix.push(item.val); + continue; + } + const item = parseTupleItem(def[index], resolve); + if (optionalSeen && !item.opt && !item.hasDefault) { + throw new OmpTypeError("required tuple elements cannot follow optional elements"); + } + optionalSeen ||= item.opt || item.hasDefault === true; + prefix.push(item); + } + return { k: "tuple", prefix, variadic, postfix }; +} + +/** Build the runtime schema for an object's or tuple's keys. */ +export function keyOf(node: IR): IR { + if (node.k === "object") { + const members: IR[] = node.props.map(prop => ({ k: "lit", v: prop.key })); + if (node.index !== undefined) members.push({ k: "string" }); + if (members.length === 0) return { k: "never" }; + return members.length === 1 ? members[0] : { k: "union", members }; + } + if (node.k === "tuple") return { k: "number", int: true, min: 0 }; + throw new OmpTypeError(`keyof requires an object or tuple (was ${node.k})`); +} + +function parseArrayExpression(def: readonly unknown[], resolve?: AliasResolver): IR { + if (def.length === 2 && def[1] === "[]") return { k: "array", el: parseDef(def[0], resolve) }; + if (def.length === 2 && def[0] === "keyof") return keyOf(parseDef(def[1], resolve)); + if (def[0] === "instanceof") { + const members: IR[] = []; + for (let index = 1; index < def.length; index++) { + const ctor = def[index]; + if (!isConstructor(ctor)) throw new OmpTypeError("instanceof operands must be constructors"); + members.push({ k: "instance", ctor, expected: `an instance of ${ctor.name || "the constructor"}` }); + } + return members.length === 1 ? members[0] : { k: "union", members }; + } + if (def[0] === "===") { + const members = def.slice(1).map(value => ({ k: "lit", v: value }) satisfies IR); + if (members.length === 0) return { k: "never" }; + return members.length === 1 ? members[0] : { k: "union", members }; + } + if (def.length >= 3 && def[1] === "|") { + return { k: "union", members: [parseDef(def[0], resolve), parseDef(def[2], resolve)] }; + } + if (def.length >= 3 && def[1] === "&") { + return { k: "intersection", members: [parseDef(def[0], resolve), parseDef(def[2], resolve)] }; + } + if (def.length === 3 && def[1] === "=>") { + if (!isCallback(def[2])) throw new OmpTypeError("morph operator requires a function"); + return { k: "morph", input: parseDef(def[0], resolve), fn: def[2] }; + } + if (def.length === 3 && def[1] === "|>") { + return { k: "morph", input: parseDef(def[0], resolve), fn: value => value, out: parseDef(def[2], resolve) }; + } + if (def.length === 3 && def[1] === ":") { + if (!isCallback(def[2])) throw new OmpTypeError("narrow operator requires a predicate"); + const predicate = def[2]; + return { + k: "refine", + base: parseDef(def[0], resolve), + pred: value => + predicate(value, { + error: () => OmpErrors.single([], "the predicate", value), + reject: () => OmpErrors.single([], "the predicate", value), + }) === true, + expected: "a value satisfying the predicate", + }; + } + if (def.length >= 3 && def[1] === "@") { + const base = parseDef(def[0], resolve); + const meta = def[2]; + if (typeof meta === "string") return { ...base, desc: meta }; + if (typeof meta === "object" && meta !== null && "description" in meta && typeof meta.description === "string") { + return { ...base, desc: meta.description }; + } + return base; + } + return parseTuple(def, resolve); +} + +/** Parse a definition, optionally resolving names from an enclosing scope. */ +export function parseDef(def: unknown, resolve?: AliasResolver): IR { + if (typeof def === "string") { + const parsed = parseStringDef(def, resolve); + if (parsed.optional) { + throw new OmpTypeError(`optional "?" marker is only valid on object property values`); + } + return parsed.ir; + } + if (isEmbedded(def)) return embed(def); + if (def instanceof RegExp) return patternIR(def); + if (def instanceof Date) return { k: "lit", v: def }; + if (Array.isArray(def)) return parseArrayExpression(def, resolve); + if (typeof def === "object" && def !== null) { + const props: PropIR[] = []; + let index: IR | undefined; + let extras: Extras = "keep"; + for (const rawKey in def) { + const val: unknown = Reflect.get(def, rawKey); + if (rawKey === "+") { + if (val === "reject" || val === "delete") extras = val; + else if (val === "ignore") extras = "keep"; + else throw new OmpTypeError(`bad "+" value ${String(val)}`); + continue; + } + if (rawKey === "...") { + const spread = parseDef(val, resolve); + if (spread.k !== "object") throw new OmpTypeError("object spread must resolve to an object"); + for (const prop of spread.props) { + const previous = props.findIndex(candidate => candidate.key === prop.key); + if (previous < 0) props.push(prop); + else props[previous] = prop; + } + index ??= spread.index; + if (spread.extras !== "keep") extras = spread.extras; + continue; + } + if (rawKey === "[string]") { + index = parseDef(val, resolve); + continue; + } + const opt = rawKey.endsWith("?"); + const key = opt ? rawKey.slice(0, -1) : rawKey; + if (typeof val === "string") { + const parsed = parseStringDef(val, resolve); + const prop: PropIR = { key, opt: opt || parsed.optional, val: parsed.ir }; + if (parsed.hasDefault) { + prop.def = parsed.def; + prop.hasDefault = true; + } + props.push(prop); + } else if (Array.isArray(val) && val.length === 2 && val[1] === "?") { + props.push({ key, opt: true, val: parseDef(val[0], resolve) }); + } else if (Array.isArray(val) && val.length === 3 && val[1] === "=") { + props.push({ + key, + opt, + val: parseDef(val[0], resolve), + def: val[2], + defFactory: typeof val[2] === "function", + hasDefault: true, + }); + } else if (isEmbedded(val) && val.hasDefault) { + props.push({ + key, + opt, + val: embed(val), + def: val.defaultValue, + defFactory: typeof val.defaultValue === "function", + hasDefault: true, + }); + } else { + props.push({ key, opt, val: parseDef(val, resolve) }); + } + } + return { k: "object", props, index, extras }; + } + throw new OmpTypeError(`unsupported definition ${String(def)}`); +} + +/** True when validating `ir` can produce an output different from its input. */ +export function hasMorph(ir: IR): boolean { + const cached = ir[kMorph]; + if (cached !== undefined) return cached; + + let result = false; + switch (ir.k) { + case "sub": + result = true; + break; + case "morph": + case "alias": + result = true; + break; + case "object": + result = ir.extras === "delete"; + for (let i = 0; !result && i < ir.props.length; i++) { + const prop = ir.props[i]; + result = prop.hasDefault === true || hasMorph(prop.val); + } + if (!result && ir.index !== undefined) result = hasMorph(ir.index); + break; + case "array": + result = hasMorph(ir.el); + break; + case "union": + result = ir.members.some(hasMorph); + break; + case "intersection": + result = ir.members.some(hasMorph); + break; + case "refine": + result = hasMorph(ir.base); + break; + case "tuple": + result = + ir.prefix.some(item => item.hasDefault === true || hasMorph(item.val)) || + (ir.variadic !== undefined && hasMorph(ir.variadic)) || + ir.postfix.some(hasMorph); + break; + } + ir[kMorph] = result; + return result; +} + +/** Human-readable expectation for error messages, e.g. `"a string"`. */ +export function expectedOf(ir: IR): string { + switch (ir.k) { + case "unknown": + return "unknown"; + case "null": + return "null"; + case "undefined": + return "undefined"; + case "boolean": + return "boolean"; + case "bigint": + return "a bigint"; + case "symbol": + return "a symbol"; + case "never": + return "never"; + case "anyobject": + return "an object"; + case "string": { + let out = ir.url ? "a URL string" : "a string"; + if (ir.min !== undefined && ir.max !== undefined) out += ` (length ${ir.min} to ${ir.max})`; + else if (ir.min !== undefined) out += ` (length at least ${ir.min})`; + else if (ir.max !== undefined) out += ` (length at most ${ir.max})`; + return out; + } + case "number": { + let out = ir.int ? "an integer" : "a number"; + if (ir.min !== undefined) out += ` ${ir.xmin ? "more than" : "at least"} ${ir.min}`; + if (ir.max !== undefined) + out += `${ir.min !== undefined ? " and" : ""} ${ir.xmax ? "less than" : "at most"} ${ir.max}`; + if (ir.divisor !== undefined) out += ` divisible by ${ir.divisor}`; + return out; + } + case "lit": + return ir.v instanceof Date + ? `the date ${ir.v.toISOString()}` + : typeof ir.v === "string" + ? JSON.stringify(ir.v) + : String(ir.v); + case "union": { + if (ir.members.length === 0) return ""; + const first = expectedOf(ir.members[0]); + if (ir.members.length === 1) return first; + const second = expectedOf(ir.members[1]); + if (ir.members.length === 2) return first === second ? first : `${first} or ${second}`; + const expectations = first === second ? [first] : [first, second]; + for (let i = 2; i < ir.members.length; i++) { + const expected = expectedOf(ir.members[i]); + if (!expectations.includes(expected)) expectations.push(expected); + } + return expectations.join(" or "); + } + case "intersection": + return ir.members.map(expectedOf).join(" and "); + case "array": + return "an array"; + case "tuple": + return "a tuple"; + case "object": + return "an object"; + case "instance": + return ir.expected; + case "refine": + return ir.expected; + case "morph": + return expectedOf(ir.input); + case "alias": + return ir.name; + case "sub": + return expectedOf(ir.schema.ir); + } +} diff --git a/packages/omptype/src/json-schema.ts b/packages/omptype/src/json-schema.ts new file mode 100644 index 000000000..3e5951bb8 --- /dev/null +++ b/packages/omptype/src/json-schema.ts @@ -0,0 +1,267 @@ +import type { IR, PropIR } from "./ir"; + +export interface JsonSchemaOptions { + description?: string; + target?: string; + dialect?: string; + fallback?: (context: { base: Record }) => unknown; +} + +type JsonSchema = Record; + +/** Emit the requested JSON Schema dialect represented by an IR tree. */ +export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema { + let schema = emit(ir, options); + if (options?.target === "draft-07") schema = toDraft7(schema); + const dialect = options?.dialect ?? dialectFor(options?.target); + if (dialect !== undefined) schema.$schema = dialect; + if (options?.description !== undefined) schema.description = options.description; + return schema; +} + +function dialectFor(target: string | undefined): string | undefined { + if (target === "draft-2020-12") return "https://json-schema.org/draft/2020-12/schema"; + if (target === "draft-07") return "http://json-schema.org/draft-07/schema#"; + return target?.startsWith("http://") || target?.startsWith("https://") ? target : undefined; +} + +function fallback(schema: JsonSchema, options?: JsonSchemaOptions): JsonSchema { + const replacement = options?.fallback?.({ base: schema }); + if (replacement === true) return {}; + if (replacement === false) return { not: {} }; + return typeof replacement === "object" && replacement !== null ? (replacement as JsonSchema) : schema; +} + +function toDraft7(schema: JsonSchema): JsonSchema { + const converted: JsonSchema = {}; + for (const key in schema) { + const value = schema[key]; + if (key === "prefixItems" && Array.isArray(value)) { + converted.items = value.map(item => + typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item, + ); + } else if (key === "items" && "prefixItems" in schema) { + converted.additionalItems = + typeof value === "object" && value !== null ? toDraft7(value as JsonSchema) : value; + } else if (Array.isArray(value)) { + converted[key] = value.map(item => + typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item, + ); + } else if (typeof value === "object" && value !== null) { + converted[key] = toDraft7(value as JsonSchema); + } else { + converted[key] = value; + } + } + return converted; +} + +function emit(ir: IR, options?: JsonSchemaOptions): JsonSchema { + let schema: JsonSchema; + switch (ir.k) { + case "unknown": + schema = {}; + break; + case "undefined": + schema = fallback({}, options); + break; + case "null": + schema = { type: "null" }; + break; + case "boolean": + schema = { type: "boolean" }; + break; + case "bigint": + schema = { type: "integer" }; + break; + case "symbol": + schema = fallback({}, options); + break; + case "never": + schema = { not: {} }; + break; + case "anyobject": + schema = { type: "object" }; + break; + case "string": + schema = emitString(ir); + break; + case "number": + schema = emitNumber(ir); + break; + case "lit": + schema = emitLiteral(ir.v); + break; + case "union": + schema = emitUnion(ir.members, options); + break; + case "intersection": + schema = { allOf: ir.members.map(member => emit(member, options)) }; + break; + case "array": + schema = { type: "array", items: emit(ir.el, options) }; + if (ir.min !== undefined) schema.minItems = ir.min; + if (ir.max !== undefined) schema.maxItems = ir.max; + break; + case "tuple": { + const prefixItems = ir.prefix.map(item => { + const itemSchema = emit(item.val, options); + if (item.hasDefault) { + itemSchema.default = item.defFactory && typeof item.def === "function" ? item.def() : item.def; + } + return itemSchema; + }); + const required = ir.prefix.reduce( + (count, item) => count + (item.opt || item.hasDefault ? 0 : 1), + ir.postfix.length, + ); + schema = { type: "array", prefixItems, minItems: required }; + if (ir.variadic === undefined) { + schema.maxItems = ir.prefix.length + ir.postfix.length; + schema.items = false; + } else { + schema.items = emit(ir.variadic, options); + } + break; + } + case "object": + schema = emitObject(ir.props, ir.index, ir.extras, options); + break; + case "instance": + schema = ir.ctor === Date ? { type: "string", format: "date-time" } : fallback({ type: "object" }, options); + break; + case "refine": + schema = emit(ir.base, options); + if (ir.json !== undefined) Object.assign(schema, ir.json); + break; + case "morph": + schema = fallback(emit(ir.out ?? ir.input, options), options); + break; + case "alias": + schema = emit(ir.resolve(), options); + break; + case "sub": + schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, options), options) : emit(ir.schema.ir, options); + if (ir.schema.description !== undefined) schema.description = ir.schema.description; + break; + } + if (ir.desc !== undefined) schema.description = ir.desc; + return schema; +} + +function emitString(ir: Extract): JsonSchema { + const schema: JsonSchema = { type: "string" }; + if (ir.min !== undefined) schema.minLength = ir.min; + if (ir.max !== undefined) schema.maxLength = ir.max; + if (ir.url) schema.format = "uri"; + return schema; +} + +function emitNumber(ir: Extract): JsonSchema { + const schema: JsonSchema = { type: ir.int ? "integer" : "number" }; + if (ir.min !== undefined) schema[ir.xmin ? "exclusiveMinimum" : "minimum"] = ir.min; + if (ir.max !== undefined) schema[ir.xmax ? "exclusiveMaximum" : "maximum"] = ir.max; + if (ir.divisor !== undefined) schema.multipleOf = ir.divisor; + return schema; +} + +function emitLiteral(value: unknown): JsonSchema { + if (value instanceof Date) return { type: "string", format: "date-time", const: value.toISOString() }; + if (isJsonValue(value)) return { const: value }; + switch (typeof value) { + case "string": + return { type: "string" }; + case "number": + return { type: "number" }; + case "boolean": + return { type: "boolean" }; + case "bigint": + return { type: "integer" }; + case "object": + return { type: "object" }; + default: + return {}; + } +} + +function emitUnion(members: IR[], options?: JsonSchemaOptions): JsonSchema { + const defined = members.filter(member => member.k !== "undefined"); + if (defined.length === 0) return {}; + if (defined.length === 1) return emit(defined[0], options); + if (defined.every(member => member.k === "lit" && isJsonValue(member.v))) { + const values = defined.map(member => (member as Extract).v); + const schema: JsonSchema = { enum: values }; + const scalarType = homogeneousScalarType(values); + if (scalarType !== undefined) schema.type = scalarType; + return schema; + } + return { anyOf: defined.map(member => emit(member, options)) }; +} + +function homogeneousScalarType(values: unknown[]): string | undefined { + const first = jsonScalarType(values[0]); + if (first === undefined) return undefined; + for (let i = 1; i < values.length; i++) { + if (jsonScalarType(values[i]) !== first) return undefined; + } + return first; +} + +function jsonScalarType(value: unknown): string | undefined { + if (value === null) return "null"; + switch (typeof value) { + case "string": + case "number": + case "boolean": + return typeof value; + default: + return undefined; + } +} + +function emitObject( + props: PropIR[], + index: IR | undefined, + extras: "keep" | "reject" | "delete", + options?: JsonSchemaOptions, +): JsonSchema { + const properties: Record = {}; + const required: string[] = []; + // ArkType emits required properties first (each group in declaration + // order); downstream wire consumers rely on that stable ordering. + const ordered = [...props.filter(p => !p.opt && !p.hasDefault), ...props.filter(p => p.opt || p.hasDefault)]; + for (const prop of ordered) { + const propertySchema = emit(prop.val, options); + if (prop.hasDefault) { + propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def; + } + properties[prop.key] = propertySchema; + if (!prop.opt && !prop.hasDefault) required.push(prop.key); + } + const schema: JsonSchema = { type: "object", properties }; + if (required.length > 0) schema.required = required; + if (index !== undefined) schema.additionalProperties = emit(index, options); + else if (extras === "reject") schema.additionalProperties = false; + return schema; +} + +function isJsonValue(value: unknown, seen = new Set()): boolean { + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (typeof value !== "object" || seen.has(value)) return false; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) { + if (!isJsonValue(item, seen)) return false; + } + seen.delete(value); + return true; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + for (const key in value) { + if (Object.hasOwn(value, key) && !isJsonValue((value as Record)[key], seen)) return false; + } + seen.delete(value); + return true; +} diff --git a/packages/omptype/src/keywords.ts b/packages/omptype/src/keywords.ts new file mode 100644 index 000000000..0f6174908 --- /dev/null +++ b/packages/omptype/src/keywords.ts @@ -0,0 +1,270 @@ +import type { IR, MorphContext } from "./ir"; + +const NUMERIC = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; +const INTEGER = /^[+-]?(?:0|[1-9]\d*)$/; +const EMAIL = /^[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,}$/; +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*))?(?:\+([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?$/; +const UUID = /^[\da-f]{8}-[\da-f]{4}-[1-8][\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i; +const IPV4_SEGMENT = "(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"; +const IPV4_ADDRESS = `(?:${IPV4_SEGMENT}[.]){3}${IPV4_SEGMENT}`; +const IPV4 = new RegExp(`^${IPV4_ADDRESS}$`); +const IPV6_SEGMENT = "(?:[0-9a-fA-F]{1,4})"; +const IPV6 = new RegExp( + "^(" + + `(?:${IPV6_SEGMENT}:){7}(?:${IPV6_SEGMENT}|:)|` + + `(?:${IPV6_SEGMENT}:){6}(?:${IPV4_ADDRESS}|:${IPV6_SEGMENT}|:)|` + + `(?:${IPV6_SEGMENT}:){5}(?::${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,2}|:)|` + + `(?:${IPV6_SEGMENT}:){4}(?:(:${IPV6_SEGMENT}){0,1}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,3}|:)|` + + `(?:${IPV6_SEGMENT}:){3}(?:(:${IPV6_SEGMENT}){0,2}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,4}|:)|` + + `(?:${IPV6_SEGMENT}:){2}(?:(:${IPV6_SEGMENT}){0,3}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,5}|:)|` + + `(?:${IPV6_SEGMENT}:){1}(?:(:${IPV6_SEGMENT}){0,4}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,6}|:)|` + + `(?::((?::${IPV6_SEGMENT}){0,5}:${IPV4_ADDRESS}|(?::${IPV6_SEGMENT}){1,7}|:))` + + ")(?:%[\\d.A-Za-z]{1,})?$", +); +const ISO_DATE = + /^([+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-3])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))(T((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([,.]\d+(?!:))?)?(\17[0-5]\d([,.]\d+)?)?([Zz]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/; + +function pattern(regex: RegExp, expected: string, format?: string): IR { + return { + k: "refine", + base: { k: "string" }, + pred: value => { + regex.lastIndex = 0; + return regex.test(value as string); + }, + expected, + json: format === undefined ? { pattern: regex.source } : { pattern: regex.source, format }, + }; +} + +function morph(input: IR, fn: (value: string, context: MorphContext) => unknown, out?: IR): IR { + return { k: "morph", input, fn: (value, context) => fn(value as string, context), out }; +} + +function parsableJson(): IR { + return { + k: "refine", + base: { k: "string" }, + pred: value => { + try { + JSON.parse(value as string); + return true; + } catch { + return false; + } + }, + expected: "a JSON string", + json: { contentMediaType: "application/json" }, + }; +} + +function parsableDate(expected = "a parsable date"): IR { + return { + k: "refine", + base: { k: "string" }, + pred: value => !Number.isNaN(new Date(value as string).valueOf()), + expected, + json: { format: "date-time" }, + }; +} + +function normalize(form: "NFC" | "NFD" | "NFKC" | "NFKD"): IR { + return morph({ k: "string" }, value => value.normalize(form), patternForNormalized(form)); +} + +function patternForNormalized(form: "NFC" | "NFD" | "NFKC" | "NFKD"): IR { + return { + k: "refine", + base: { k: "string" }, + pred: value => (value as string).normalize(form) === value, + expected: `${form}-normalized unicode`, + }; +} + +function uuidVersion(version: string): IR { + return pattern( + new RegExp(`^[\\da-f]{8}-[\\da-f]{4}-${version}[\\da-f]{3}-[89ab][\\da-f]{3}-[\\da-f]{12}$`, "i"), + `a UUIDv${version}`, + "uuid", + ); +} + +function isLuhnValid(input: string): boolean { + const value = input.replace(/[ -]+/g, ""); + let sum = 0; + let double = false; + for (let index = value.length - 1; index >= 0; index--) { + let digit = Number(value[index]); + if (double) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + double = !double; + } + return value.length > 0 && sum % 10 === 0; +} + +const keywordFactories: Record IR> = { + string: () => ({ k: "string" }), + "string.alpha": () => pattern(/^[A-Za-z]*$/, "only letters"), + "string.alphanumeric": () => pattern(/^[\dA-Za-z]*$/, "only letters and digits 0-9"), + "string.hex": () => pattern(/^[\dA-Fa-f]+$/, "hex characters only"), + "string.base64": () => pattern(/^(?:[\d+/A-Za-z]{4})*(?:[\d+/A-Za-z]{2}==|[\d+/A-Za-z]{3}=)?$/, "base64-encoded"), + "string.base64.url": () => + pattern(/^(?:[\w-]{4})*(?:[\w-]{2}(?:==|%3D%3D)?|[\w-]{3}(?:=|%3D)?)?$/, "base64url-encoded"), + "string.capitalize": () => morph({ k: "string" }, value => value.charAt(0).toUpperCase() + value.slice(1)), + "string.capitalize.preformatted": () => pattern(/^[A-Z].*$/, "capitalized"), + "string.creditCard": () => ({ + k: "refine", + base: pattern( + /^(?:4\d{12}(?:\d{3,6})?|5[1-5]\d{14}|3[47]\d{13}|6(?:011|5\d\d)\d{12,15})$/, + "a credit card number", + ), + pred: value => isLuhnValid(value as string), + expected: "a credit card number", + }), + "string.date": () => parsableDate(), + "string.date.parse": () => + morph(parsableDate(), value => new Date(value), { k: "instance", ctor: Date, expected: "a Date" }), + "string.date.iso": () => pattern(ISO_DATE, "an ISO 8601 date", "date-time"), + "string.date.iso.parse": () => + morph(pattern(ISO_DATE, "an ISO 8601 date", "date-time"), value => new Date(value), { + k: "instance", + ctor: Date, + expected: "a Date", + }), + "string.date.epoch": () => pattern(INTEGER, "an integer string representing a Unix timestamp"), + "string.date.epoch.parse": () => + morph(pattern(INTEGER, "an integer string representing a Unix timestamp"), value => new Date(Number(value)), { + k: "instance", + ctor: Date, + expected: "a Date", + }), + "string.digits": () => pattern(/^\d*$/, "only digits 0-9"), + "string.email": () => pattern(EMAIL, "an email address", "email"), + "string.integer": () => pattern(INTEGER, "a well-formed integer string"), + "string.integer.parse": () => + morph( + pattern(INTEGER, "a well-formed integer string"), + (value, context) => { + const parsed = Number.parseInt(value, 10); + return Number.isSafeInteger(parsed) ? parsed : context.error("a safe integer string"); + }, + { k: "number", int: true }, + ), + "string.ip": () => ({ + k: "union", + members: [pattern(IPV4, "an IPv4 address", "ipv4"), pattern(IPV6, "an IPv6 address", "ipv6")], + }), + "string.ip.v4": () => pattern(IPV4, "an IPv4 address", "ipv4"), + "string.ip.v6": () => pattern(IPV6, "an IPv6 address", "ipv6"), + "string.json": () => parsableJson(), + "string.json.parse": () => + morph(parsableJson(), (value, context) => { + try { + return JSON.parse(value); + } catch { + return context.error("a JSON string"); + } + }), + "string.lower": () => morph({ k: "string" }, value => value.toLowerCase()), + "string.lower.preformatted": () => pattern(/^[a-z]*$/, "only lowercase letters"), + "string.normalize": () => normalize("NFC"), + "string.normalize.NFC": () => normalize("NFC"), + "string.normalize.NFC.preformatted": () => patternForNormalized("NFC"), + "string.normalize.NFD": () => normalize("NFD"), + "string.normalize.NFD.preformatted": () => patternForNormalized("NFD"), + "string.normalize.NFKC": () => normalize("NFKC"), + "string.normalize.NFKC.preformatted": () => patternForNormalized("NFKC"), + "string.normalize.NFKD": () => normalize("NFKD"), + "string.normalize.NFKD.preformatted": () => patternForNormalized("NFKD"), + "string.numeric": () => pattern(NUMERIC, "a well-formed numeric string"), + "string.numeric.parse": () => + morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }), + "string.regex": () => ({ + k: "refine", + base: { k: "string" }, + pred: value => { + try { + new RegExp(value as string); + return true; + } catch { + return false; + } + }, + expected: "a regex pattern", + json: { format: "regex" }, + }), + "string.semver": () => pattern(SEMVER, "a semantic version"), + "string.trim": () => morph({ k: "string" }, value => value.trim()), + "string.trim.preformatted": () => pattern(/^\S.*\S$|^\S?$/, "trimmed"), + "string.upper": () => morph({ k: "string" }, value => value.toUpperCase()), + "string.upper.preformatted": () => pattern(/^[A-Z]*$/, "only uppercase letters"), + "string.url": () => ({ k: "string", url: true }), + "string.url.parse": () => + morph({ k: "string", url: true }, value => new URL(value), { k: "instance", ctor: URL, expected: "a URL" }), + "string.uuid": () => pattern(UUID, "a UUID", "uuid"), + "string.uuid.v1": () => uuidVersion("1"), + "string.uuid.v2": () => uuidVersion("2"), + "string.uuid.v3": () => uuidVersion("3"), + "string.uuid.v4": () => uuidVersion("4"), + "string.uuid.v5": () => uuidVersion("5"), + "string.uuid.v6": () => uuidVersion("6"), + "string.uuid.v7": () => uuidVersion("7"), + "string.uuid.v8": () => uuidVersion("8"), + "parse.number": () => + morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }), + "parse.integer": () => + morph(pattern(INTEGER, "a well-formed integer string"), value => Number.parseInt(value, 10), { + k: "number", + int: true, + }), + "parse.json": () => keywordFactories["string.json.parse"](), + "parse.date": () => keywordFactories["string.date.parse"](), + "parse.url": () => keywordFactories["string.url.parse"](), + "parse.boolean": () => + morph(pattern(/^(?:true|false)$/, "a boolean string"), value => value === "true", { k: "boolean" }), + "parse.bigint": () => morph(pattern(INTEGER, "an integer string"), value => BigInt(value), { k: "bigint" }), +}; + +/** Lower a built-in keyword into fresh validation IR. */ +export function keywordIR(name: string): IR | undefined { + return keywordFactories[name]?.(); +} + +/** Lower a regular expression into a string refinement. */ +export function patternIR(regex: RegExp): IR { + return pattern(regex, `a string matching ${regex}`); +} + +/** Lower an ArkType-style template literal into a string pattern. */ +export function templateIR(source: string): IR { + let patternSource = "^"; + let index = 0; + const placeholder = /\$\{(string|number|bigint|boolean)\}/g; + for (let match = placeholder.exec(source); match; match = placeholder.exec(source)) { + patternSource += escapeRegex(source.slice(index, match.index)); + switch (match[1]) { + case "number": + patternSource += "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)"; + break; + case "bigint": + patternSource += "[+-]?\\d+"; + break; + case "boolean": + patternSource += "(?:true|false)"; + break; + default: + patternSource += ".*"; + } + index = match.index + match[0].length; + } + patternSource += `${escapeRegex(source.slice(index))}$`; + return pattern(new RegExp(patternSource), `a string matching \`${source}\``); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/omptype/src/type.ts b/packages/omptype/src/type.ts new file mode 100644 index 000000000..32e234947 --- /dev/null +++ b/packages/omptype/src/type.ts @@ -0,0 +1,1468 @@ +/** + * The public `type()` parser and `Type` schema surface — an ArkType-compatible + * validator with a lazy JIT: + * + * - calls 1-2 run the tree-walking interpreter (near-zero setup cost, so + * schemas built per-request or validated once stay cheap) + * - the third call compiles a specialized validator via `new Function` and + * swaps it in; hot schemas validate in tens of nanoseconds + * + * A schema is a callable: `schema(data)` returns the (possibly morphed) + * output, or an `OmpErrors` on failure (`result instanceof type.errors`). + */ +import { compile } from "./compile"; +import { type ErrorConfig, OmpErrors, OmpTypeError, TraversalError } from "./errors"; +import type { InferDef, InferDefIn, InferObjectLiteral, InferString } from "./infer"; +import { walk } from "./interp"; +import { + type AliasResolver, + type Constructor, + type Def, + embed, + expectedOf, + hasMorph, + type IR, + IR_BRAND, + keyOf, + type PropIR, + parseDef, +} from "./ir"; +import { irToJsonSchema, type JsonSchemaOptions } from "./json-schema"; +import { keywordIR, patternIR } from "./keywords"; + +/** Context passed to `.narrow()` / `.pipe()` callbacks. */ +export interface NarrowContext { + /** Record `must be ` and signal failure. */ + mustBe(expectation: string): false; + /** Record a custom problem and signal failure. */ + reject(problem: string): false; +} + +/** Schema metadata and validation-message overrides accepted by `.configure()`. */ +export interface SchemaConfig extends ErrorConfig { + readonly description?: string; +} + +/** Options accepted by `Type.toJsonSchema`. */ +export interface ToJsonSchemaOptions extends JsonSchemaOptions {} + +declare const brand: unique symbol; + +/** Inference-only nominal brand attached by `.brand(name)`. */ +export type Brand = t & { readonly [brand]: name }; + +interface SchemaInference { + readonly [IR_BRAND]: true; + readonly infer: t; + readonly inferIn: i; +} + +/** Property descriptor exposed by object schemas and consumed by `.map()`. */ +export interface TypeProperty { + readonly kind: "required" | "optional"; + readonly key: PropertyKey; + readonly value: FluentType; + readonly default?: unknown; + readonly meta: Readonly>; +} + +/** Structural node returned by `.select()`. */ +export interface SelectedNode { + readonly kind: string; + readonly node: IR; + readonly unit?: unknown; +} + +/** A compiled schema: callable validator plus composition methods. */ +export interface Type { + (data: unknown): t | OmpErrors; + readonly [IR_BRAND]: true; + /** Structural IR (base type; runtime steps live in `steps`). */ + readonly ir: IR; + /** `.pipe()` / `.narrow()` steps applied after structural validation. */ + readonly hasSteps: boolean; + readonly hasDefault: boolean; + readonly defaultValue?: unknown; + readonly description?: string; + /** Full validate+morph pipeline; identical to calling the schema. */ + readonly run: (data: unknown) => unknown; + + /** Inference-only output type (no runtime value). */ + readonly infer: t; + /** Inference-only input type (no runtime value). */ + readonly inferIn: i; + + /** Structural + narrow check without running pipes. */ + allows(data: unknown): data is i; + /** Validate and return output, throwing `TraversalError` on failure. */ + assert(data: unknown): t; + /** Validate a statically typed input and return its output. */ + from(data: i): t; + /** JSON Schema for this schema's structural base. */ + toJsonSchema(options?: ToJsonSchemaOptions): Record; +} + +type MergeTypes = left extends object + ? right extends object + ? Omit & right + : right + : right; + +interface FluentMethods { + describe(description: string): FluentType; + configure(config: SchemaConfig): FluentType; + default(value: t | (() => t)): FluentType; + optional(): readonly [SchemaInference, "?"]; + or(def: SchemaInference): FluentType; + or(def: def): FluentType, i | InferString>; + or>( + def: def, + ): FluentType, i | InferObjectLiteral>; + or(def: Def): FluentType; + and(def: SchemaInference): FluentType; + and>( + def: def, + ): FluentType, i & InferObjectLiteral>; + and(def: Def): FluentType; + equals(def: Def): boolean; + ifEquals(def: Def): FluentType | undefined; + extends(def: Def): boolean; + overlaps(def: Def): boolean; + distribute(mapper: (branch: FluentType) => SchemaInference): FluentType; + select(kind: string): readonly SelectedNode[]; + array(): FluentType; + atLeastLength(bound: number): FluentType; + atMostLength(bound: number): FluentType; + moreThanLength(bound: number): FluentType; + lessThanLength(bound: number): FluentType; + exactlyLength(bound: number): FluentType; + atLeast(bound: number): FluentType; + atMost(bound: number): FluentType; + moreThan(bound: number): FluentType; + lessThan(bound: number): FluentType; + divisibleBy(divisor: number): FluentType; + positive(): FluentType; + negative(): FluentType; + nonNegative(): FluentType; + nonPositive(): FluentType; + matching(pattern: RegExp): FluentType; + atOrAfter(bound: Date): FluentType; + atOrBefore(bound: Date): FluentType; + laterThan(bound: Date): FluentType; + earlierThan(bound: Date): FluentType; + pipe(fn: (data: t, ctx: NarrowContext) => r): FluentType, i>; + to(def: def): FluentType, i>; + filter(fn: (data: i, ctx: NarrowContext) => data is narrowed): FluentType; + filter(fn: (data: i, ctx: NarrowContext) => boolean): FluentType; + narrow(fn: (data: t, ctx: NarrowContext) => data is narrowed): FluentType; + narrow(fn: (data: t, ctx: NarrowContext) => boolean): FluentType; + brand(name: name): FluentType, i>; + as(): FluentType; + extract(def: SchemaInference): FluentType, Extract>; + extract(def: def): FluentType>, Extract>>; + extract(def: Def): FluentType; + exclude(def: SchemaInference): FluentType, Exclude>; + exclude(def: def): FluentType>, Exclude>>; + exclude(def: Def): FluentType; + onUndeclaredKey(behavior: "ignore" | "reject" | "delete"): FluentType; + onDeepUndeclaredKey(behavior: "ignore" | "reject" | "delete"): FluentType; +} + +type InputObject = i extends object ? i : object; + +interface ObjectMethods { + readonly props: readonly TypeProperty[]; + map( + mapper: (property: TypeProperty) => TypeProperty | readonly TypeProperty[], + ): FluentType>; + keyof(): FluentType, Extract, PropertyKey>>; + get( + key: key, + ): FluentType ? InputObject[key] : unknown>; + pick( + ...keys: keys + ): FluentType, Pick, Extract>>>; + omit( + ...keys: keys + ): FluentType, Omit, Extract>>>; + partial(): FluentType, Partial>>; + required(): FluentType, Required>>; + merge(def: SchemaInference): FluentType, MergeTypes>; + merge>( + def: def, + ): FluentType>, MergeTypes>>; + merge(def: Def): FluentType; +} + +type ObjectMethodsFor = [t] extends [never] + ? unknown + : [t] extends [readonly unknown[]] + ? unknown + : [t] extends [object] + ? ObjectMethods + : unknown; +/** Callable schema with fluent methods specialized to its output and input. */ +export type FluentType = Type & FluentMethods & ObjectMethodsFor; + +/** Runtime constructor-like value used by ArkType-compatible `instanceof Type` checks. */ +export const Type = Object.defineProperty(function Type(): void {}, Symbol.hasInstance, { + value: (value: unknown): boolean => + (typeof value === "function" || (typeof value === "object" && value !== null)) && IR_BRAND in value, +}); + +interface Step { + kind: "pipe" | "narrow" | "filter"; + fn: (data: unknown, ctx: NarrowContext) => unknown; +} + +interface TypeMeta { + description?: string; + defaultValue?: unknown; + hasDefault?: boolean; + errorConfig?: ErrorConfig; +} + +class Ctx implements NarrowContext { + expectation: string | undefined; + + mustBe(expectation: string): false { + this.expectation = expectation; + return false; + } + + reject(problem: string): false { + this.expectation = problem; + return false; + } +} + +type Validator = (data: unknown) => unknown; + +const kBase = Symbol("omptype.base"); +const kSteps = Symbol("omptype.steps"); + +const EMPTY_STEPS: Step[] = []; +const EMPTY_META: TypeMeta = {}; + +interface InternalType { + (data: unknown): unknown; + [IR_BRAND]: true; + [kBase]: Validator; + [kSteps]: Step[]; + ir: IR; + hasSteps: boolean; + hasDefault: boolean; + defaultValue?: unknown; + description?: string; + errorConfig?: ErrorConfig; + run: Validator; +} + +/** Calls before the JIT compiles a schema (first two run the interpreter). */ +const JIT_THRESHOLD = 3; + +function metaOf(schema: InternalType): TypeMeta { + return { + description: schema.description, + defaultValue: schema.defaultValue, + hasDefault: schema.hasDefault, + errorConfig: schema.errorConfig, + }; +} + +const typeMethods = { + describe(this: InternalType, description: string): InternalType { + return makeType({ ...this.ir, desc: description }, this[kSteps], { ...metaOf(this), description }); + }, + + configure(this: InternalType, config: SchemaConfig): InternalType { + const errorConfig: ErrorConfig = { + ...this.errorConfig, + ...(config.expected === undefined ? {} : { expected: config.expected }), + ...(config.actual === undefined ? {} : { actual: config.actual }), + ...(config.problem === undefined ? {} : { problem: config.problem }), + ...(config.message === undefined ? {} : { message: config.message }), + }; + const meta = { + ...metaOf(this), + errorConfig, + ...(config.description === undefined ? {} : { description: config.description }), + }; + return config.description === undefined + ? makeType(this.ir, this[kSteps], meta) + : makeType({ ...this.ir, desc: config.description }, this[kSteps], meta); + }, + + default(this: InternalType, value: unknown): InternalType { + return makeType(this.ir, this[kSteps], { ...metaOf(this), defaultValue: value, hasDefault: true }); + }, + + optional(this: InternalType): readonly [InternalType, "?"] { + return [this, "?"]; + }, + + or(this: InternalType, def: Def): InternalType { + const other = parseDef(def); + const a = embed(this); + const members = [...(a.k === "union" ? a.members : [a]), ...(other.k === "union" ? other.members : [other])]; + return makeType({ k: "union", members }, [], {}); + }, + + equals(this: InternalType, def: Def): boolean { + return irEquals(embed(this), parseDef(def)); + }, + + ifEquals(this: InternalType, def: Def): InternalType | undefined { + return irEquals(embed(this), parseDef(def)) ? this : undefined; + }, + + extends(this: InternalType, def: Def): boolean { + return isSubtype(embed(this), parseDef(def)); + }, + + overlaps(this: InternalType, def: Def): boolean { + try { + intersect(embed(this), parseDef(def)); + return true; + } catch (error) { + if (error instanceof OmpTypeError) return false; + throw error; + } + }, + + distribute(this: InternalType, mapper: (branch: BaseType) => InternalType): InternalType { + const branches = this.ir.k === "union" ? this.ir.members : [embed(this)]; + const members = branches.map(branch => embed(mapper(makeType(branch, [], {}) as unknown as BaseType))); + return makeType(members.length === 1 ? members[0] : { k: "union", members }, [], {}); + }, + + select(this: InternalType, kind: string): readonly SelectedNode[] { + return selectNodes(this.ir, kind); + }, + + and(this: InternalType, def: Def): InternalType { + return makeType(intersect(embed(this), parseDef(def)), [], {}); + }, + + array(this: InternalType): InternalType { + return makeType({ k: "array", el: embed(this) }, [], {}); + }, + + atLeastLength(this: InternalType, bound: number): InternalType { + return makeType(withLengthBound(this.ir, "min", bound), this[kSteps], metaOf(this)); + }, + + atMostLength(this: InternalType, bound: number): InternalType { + return makeType(withLengthBound(this.ir, "max", bound), this[kSteps], metaOf(this)); + }, + + moreThanLength(this: InternalType, bound: number): InternalType { + return makeType(withLengthBound(this.ir, "min", bound + 1), this[kSteps], metaOf(this)); + }, + + lessThanLength(this: InternalType, bound: number): InternalType { + return makeType(withLengthBound(this.ir, "max", bound - 1), this[kSteps], metaOf(this)); + }, + + exactlyLength(this: InternalType, bound: number): InternalType { + const bounded = withLengthBound(withLengthBound(this.ir, "min", bound), "max", bound); + return makeType(bounded, this[kSteps], metaOf(this)); + }, + + atLeast(this: InternalType, bound: number): InternalType { + return makeType(withNumericBound(this.ir, "min", bound), this[kSteps], metaOf(this)); + }, + + atMost(this: InternalType, bound: number): InternalType { + return makeType(withNumericBound(this.ir, "max", bound), this[kSteps], metaOf(this)); + }, + + moreThan(this: InternalType, bound: number): InternalType { + return makeType(withNumericBound(this.ir, "min", bound, true), this[kSteps], metaOf(this)); + }, + + lessThan(this: InternalType, bound: number): InternalType { + return makeType(withNumericBound(this.ir, "max", bound, true), this[kSteps], metaOf(this)); + }, + + divisibleBy(this: InternalType, divisor: number): InternalType { + if (this.ir.k !== "number") throw new OmpTypeError(`cannot apply divisibility to ${this.ir.k}`); + if (!Number.isFinite(divisor) || divisor === 0) throw new OmpTypeError("divisor must be non-zero"); + return makeType({ ...this.ir, divisor }, this[kSteps], metaOf(this)); + }, + + positive(this: InternalType): InternalType { + return makeType(withNumericBound(this.ir, "min", 0, true), this[kSteps], metaOf(this)); + }, + + negative(this: InternalType): InternalType { + return makeType(withNumericBound(this.ir, "max", 0, true), this[kSteps], metaOf(this)); + }, + + nonNegative(this: InternalType): InternalType { + return makeType(withNumericBound(this.ir, "min", 0), this[kSteps], metaOf(this)); + }, + + nonPositive(this: InternalType): InternalType { + return makeType(withNumericBound(this.ir, "max", 0), this[kSteps], metaOf(this)); + }, + + matching(this: InternalType, pattern: RegExp): InternalType { + return makeType(intersect(this.ir, patternIR(pattern)), this[kSteps], metaOf(this)); + }, + + atOrAfter(this: InternalType, bound: Date): InternalType { + return dateRefinement(this, bound, "at or after", value => value >= bound.valueOf()); + }, + + atOrBefore(this: InternalType, bound: Date): InternalType { + return dateRefinement(this, bound, "at or before", value => value <= bound.valueOf()); + }, + + laterThan(this: InternalType, bound: Date): InternalType { + return dateRefinement(this, bound, "later than", value => value > bound.valueOf()); + }, + + earlierThan(this: InternalType, bound: Date): InternalType { + return dateRefinement(this, bound, "earlier than", value => value < bound.valueOf()); + }, + + pipe(this: InternalType, fn: Step["fn"]): InternalType { + return makeType(this.ir, [...this[kSteps], { kind: "pipe", fn }], metaOf(this)); + }, + + to(this: InternalType, def: unknown): InternalType { + const output = makeType(parseDef(def), [], {}); + return makeType( + this.ir, + [ + ...this[kSteps], + { + kind: "pipe", + fn: value => output(value), + }, + ], + metaOf(this), + ); + }, + + filter(this: InternalType, fn: Step["fn"]): InternalType { + return makeType(this.ir, [{ kind: "filter", fn }, ...this[kSteps]], metaOf(this)); + }, + + narrow(this: InternalType, fn: Step["fn"]): InternalType { + return makeType(this.ir, [...this[kSteps], { kind: "narrow", fn }], metaOf(this)); + }, + + brand(this: InternalType): InternalType { + return makeType(this.ir, this[kSteps], metaOf(this)); + }, + + as(this: InternalType): InternalType { + return makeType(this.ir, this[kSteps], metaOf(this)); + }, + + keyof(this: InternalType): InternalType { + return makeType(keyOf(this.ir), [], {}); + }, + + get(this: InternalType, key: PropertyKey): InternalType { + const object = requireObject(this.ir, "get"); + const prop = object.props.find(candidate => candidate.key === String(key)); + if (!prop) throw new OmpTypeError(`key ${String(key)} is not declared`); + const ir: IR = prop.opt ? { k: "union", members: [prop.val, { k: "undefined" }] } : prop.val; + return makeType(ir, [], {}); + }, + + pick(this: InternalType, ...keys: PropertyKey[]): InternalType { + const object = requireObject(this.ir, "pick"); + const selected = new Set(keys.map(String)); + return makeType({ ...object, props: object.props.filter(prop => selected.has(prop.key)) }, [], {}); + }, + + omit(this: InternalType, ...keys: PropertyKey[]): InternalType { + const object = requireObject(this.ir, "omit"); + const omitted = new Set(keys.map(String)); + return makeType({ ...object, props: object.props.filter(prop => !omitted.has(prop.key)) }, [], {}); + }, + + partial(this: InternalType): InternalType { + const object = requireObject(this.ir, "partial"); + return makeType({ ...object, props: object.props.map(prop => ({ ...prop, opt: true })) }, [], {}); + }, + + required(this: InternalType): InternalType { + const object = requireObject(this.ir, "required"); + return makeType({ ...object, props: object.props.map(prop => ({ ...prop, opt: false })) }, [], {}); + }, + + map(this: InternalType, mapper: (property: TypeProperty) => TypeProperty | readonly TypeProperty[]): InternalType { + const object = requireObject(this.ir, "map"); + const props = object.props.flatMap(prop => { + const mapped = mapper(propertyFromIR(prop)); + return (Array.isArray(mapped) ? mapped : [mapped]).map(propertyToIR); + }); + return makeType({ ...object, props }, [], {}); + }, + + merge(this: InternalType, def: unknown): InternalType { + return makeType(mergeObjects(requireObject(this.ir, "merge"), requireObject(parseDef(def), "merge")), [], {}); + }, + + extract(this: InternalType, def: unknown): InternalType { + const other = parseDef(def); + return makeType( + { + k: "refine", + base: embed(this), + pred: value => !(walk(other, value) instanceof OmpErrors), + expected: "a value included by the extracted type", + }, + [], + {}, + ); + }, + + exclude(this: InternalType, def: unknown): InternalType { + const other = parseDef(def); + return makeType( + { + k: "refine", + base: embed(this), + pred: value => walk(other, value) instanceof OmpErrors, + expected: "a value not excluded by the type", + }, + [], + {}, + ); + }, + + onUndeclaredKey(this: InternalType, behavior: "ignore" | "reject" | "delete"): InternalType { + const object = requireObject(this.ir, "onUndeclaredKey"); + return makeType({ ...object, extras: behavior === "ignore" ? "keep" : behavior }, this[kSteps], metaOf(this)); + }, + + onDeepUndeclaredKey(this: InternalType, behavior: "ignore" | "reject" | "delete"): InternalType { + return makeType(withDeepExtras(this.ir, behavior === "ignore" ? "keep" : behavior), this[kSteps], metaOf(this)); + }, + + allows(this: InternalType, data: unknown): boolean { + for (const step of this[kSteps]) { + if (step.kind === "filter" && !step.fn(data, new Ctx())) return false; + } + const out = this[kBase](data); + if (out instanceof OmpErrors) return false; + for (const step of this[kSteps]) { + if (step.kind === "narrow" && !step.fn(out, new Ctx())) return false; + } + return true; + }, + + assert(this: InternalType, data: unknown): unknown { + const out = this.run(data); + if (out instanceof OmpErrors) throw new TraversalError(out); + return out; + }, + + from(this: InternalType, data: unknown): unknown { + const out = this.run(data); + if (out instanceof OmpErrors) throw new TraversalError(out); + return out; + }, + + toJsonSchema(this: InternalType, options?: ToJsonSchemaOptions): Record { + const description = options?.description ?? this.description; + return irToJsonSchema( + this.ir, + options === undefined && description === undefined + ? undefined + : { ...options, ...(description === undefined ? {} : { description }) }, + ); + }, +}; + +Object.defineProperty(typeMethods, "props", { + get(this: InternalType): readonly TypeProperty[] { + const object = requireObject(this.ir, "props"); + return object.props.map(prop => propertyFromIR(prop)); + }, +}); + +// Share the fluent surface without per-schema method allocations or copies. +// Function.prototype remains in the chain, except bind is intentionally hidden +// so generic tool wrappers recognize callable schemas rather than rebinding them. +Object.setPrototypeOf(typeMethods, Function.prototype); +Object.defineProperty(typeMethods, "bind", { value: undefined }); + +function makeType(ir: IR, steps: Step[], meta: TypeMeta): InternalType; +function makeType(ir: IR, steps: Step[], meta: TypeMeta): FluentType; +function makeType(ir: IR, steps: Step[], meta: TypeMeta): unknown { + let calls = 0; + let impl: Validator = (data: unknown): unknown => { + if (++calls >= JIT_THRESHOLD) { + impl = compile(ir); + return impl(data); + } + return walk(ir, data); + }; + + const base: Validator = + meta.errorConfig === undefined + ? (data: unknown): unknown => impl(data) + : (data: unknown): unknown => { + const result = impl(data); + return result instanceof OmpErrors ? result.configure(meta.errorConfig ?? {}) : result; + }; + + const callable: Validator = + steps.length === 0 + ? base + : (data: unknown): unknown => { + for (const step of steps) { + if (step.kind !== "filter") continue; + const ctx = new Ctx(); + if (!step.fn(data, ctx)) { + return OmpErrors.single( + [], + ctx.expectation ?? "valid (input predicate failed)", + data, + meta.errorConfig, + ); + } + } + let out = base(data); + if (out instanceof OmpErrors) return out; + for (const step of steps) { + if (step.kind === "filter") continue; + const ctx = new Ctx(); + if (step.kind === "narrow") { + if (!step.fn(out, ctx)) { + return OmpErrors.single( + [], + ctx.expectation ?? "valid (narrow predicate failed)", + out, + meta.errorConfig, + ); + } + } else { + out = step.fn(out, ctx); + if (out instanceof OmpErrors) { + return meta.errorConfig === undefined ? out : out.configure(meta.errorConfig); + } + } + } + return out; + }; + + const self = callable as InternalType; + self[IR_BRAND] = true; + self[kBase] = base; + self[kSteps] = steps; + self.ir = ir; + self.hasSteps = steps.length > 0; + self.hasDefault = meta.hasDefault === true; + self.defaultValue = meta.defaultValue; + self.description = meta.description; + self.errorConfig = meta.errorConfig; + self.run = callable; + Object.setPrototypeOf(self, typeMethods); + return self; +} + +type ObjectIR = Extract; + +function requireObject(ir: IR, operation: string): ObjectIR { + if (ir.k !== "object") throw new OmpTypeError(`${operation} requires an object schema`); + return ir; +} + +function propertyFromIR(prop: PropIR): TypeProperty { + return { + kind: prop.opt ? "optional" : "required", + key: prop.key, + value: makeType(prop.val, [], {}) as unknown as FluentType, + ...(prop.hasDefault ? { default: prop.def } : {}), + meta: {}, + }; +} + +function propertyToIR(property: TypeProperty): PropIR { + if (property.kind !== "required" && property.kind !== "optional") { + throw new OmpTypeError(`mapped property ${String(property.key)} has invalid kind`); + } + if (!(IR_BRAND in property.value)) { + throw new OmpTypeError(`mapped property ${String(property.key)} must contain a schema value`); + } + const hasDefault = Object.hasOwn(property, "default"); + return { + key: String(property.key), + opt: property.kind === "optional", + val: embed(property.value), + ...(hasDefault + ? { def: property.default, defFactory: typeof property.default === "function", hasDefault: true } + : {}), + }; +} + +function dateRefinement( + schema: InternalType, + bound: Date, + relation: string, + predicate: (value: number) => boolean, +): InternalType { + if (!Number.isFinite(bound.valueOf())) throw new OmpTypeError("date bound must be valid"); + return makeType( + { + k: "refine", + base: schema.ir, + pred: value => value instanceof Date && predicate(value.valueOf()), + expected: `a Date ${relation} ${bound.toISOString()}`, + json: relation.includes("after") ? { minimum: bound.toISOString() } : { maximum: bound.toISOString() }, + }, + schema[kSteps], + metaOf(schema), + ); +} + +function selectNodes(root: IR, kind: string): readonly SelectedNode[] { + const selected: SelectedNode[] = []; + const seen = new Set(); + const visit = (node: IR): void => { + if (seen.has(node)) return; + seen.add(node); + const nodeKind = node.k === "lit" ? "unit" : node.k; + if (kind === nodeKind || kind === node.k) { + selected.push(node.k === "lit" ? { kind: nodeKind, node, unit: node.v } : { kind: nodeKind, node }); + } + switch (node.k) { + case "alias": + visit(node.resolve()); + break; + case "array": + visit(node.el); + break; + case "tuple": + for (const item of node.prefix) visit(item.val); + if (node.variadic !== undefined) visit(node.variadic); + for (const item of node.postfix) visit(item); + break; + case "object": + for (const prop of node.props) visit(prop.val); + if (node.index !== undefined) visit(node.index); + break; + case "union": + case "intersection": + for (const member of node.members) visit(member); + break; + case "refine": + visit(node.base); + break; + case "morph": + visit(node.input); + if (node.out !== undefined) visit(node.out); + break; + case "sub": + visit(node.schema.ir); + break; + } + }; + visit(root); + return selected; +} + +function mergeObjects(left: ObjectIR, right: ObjectIR): ObjectIR { + const props = [...left.props]; + for (const prop of right.props) { + const index = props.findIndex(candidate => candidate.key === prop.key); + if (index < 0) props.push(prop); + else props[index] = prop; + } + return { + k: "object", + props, + index: right.index ?? left.index, + extras: right.extras === "keep" ? left.extras : right.extras, + }; +} + +function withDeepExtras(ir: IR, extras: ObjectIR["extras"]): IR { + switch (ir.k) { + case "object": + return { + ...ir, + extras, + props: ir.props.map(prop => ({ ...prop, val: withDeepExtras(prop.val, extras) })), + index: ir.index === undefined ? undefined : withDeepExtras(ir.index, extras), + }; + case "array": + return { ...ir, el: withDeepExtras(ir.el, extras) }; + case "tuple": + return { + ...ir, + prefix: ir.prefix.map(item => ({ ...item, val: withDeepExtras(item.val, extras) })), + variadic: ir.variadic === undefined ? undefined : withDeepExtras(ir.variadic, extras), + postfix: ir.postfix.map(item => withDeepExtras(item, extras)), + }; + case "union": + case "intersection": + return { ...ir, members: ir.members.map(member => withDeepExtras(member, extras)) }; + case "refine": + return { ...ir, base: withDeepExtras(ir.base, extras) }; + case "morph": + return { + ...ir, + input: withDeepExtras(ir.input, extras), + out: ir.out === undefined ? undefined : withDeepExtras(ir.out, extras), + }; + default: + return ir; + } +} + +/** Intersect two IR nodes, rejecting statically disjoint domains. */ +function intersect(a: IR, b: IR): IR { + if (a.k === "alias") return intersect(a.resolve(), b); + if (b.k === "alias") return intersect(a, b.resolve()); + if (a.k === "never" || b.k === "never") throw new OmpTypeError("intersection with never is unsatisfiable"); + if (a.k === "unknown") return b; + if (b.k === "unknown") return a; + if (a === b) return a; + if (a.k === "union" || b.k === "union") { + const union = a.k === "union" ? a : b.k === "union" ? b : undefined; + if (union === undefined) throw new OmpTypeError("union intersection invariant failed"); + const branches = union.members; + const other = a.k === "union" ? b : a; + const members: IR[] = []; + for (const branch of branches) { + try { + members.push(intersect(branch, other)); + } catch (error) { + if (!(error instanceof OmpTypeError)) throw error; + } + } + if (members.length === 0) throw new OmpTypeError("intersection has no satisfiable branches"); + return members.length === 1 ? members[0] : { k: "union", members }; + } + if (a.k === "lit") { + if (walk(b, a.v) instanceof OmpErrors) throw new OmpTypeError("literal is excluded by the intersection"); + return a; + } + if (b.k === "lit") return intersect(b, a); + if (a.k === "object" && b.k === "object") { + const props = [...a.props]; + for (const bp of b.props) { + const index = props.findIndex(prop => prop.key === bp.key); + if (index < 0) props.push(bp); + else { + const ap = props[index]; + props[index] = { ...ap, opt: ap.opt && bp.opt, val: intersect(ap.val, bp.val) }; + } + } + const extras = + a.extras === "reject" || b.extras === "reject" + ? "reject" + : a.extras === "delete" || b.extras === "delete" + ? "delete" + : "keep"; + const index = a.index && b.index ? intersect(a.index, b.index) : (a.index ?? b.index); + return { k: "object", props, index, extras }; + } + if (a.k === "string" && b.k === "string") { + const min = maxOf(a.min, b.min); + const max = minOf(a.max, b.max); + if (min !== undefined && max !== undefined && min > max) { + throw new OmpTypeError("string length intersection is unsatisfiable"); + } + return { k: "string", min, max, url: a.url || b.url }; + } + if (a.k === "number" && b.k === "number") { + const min = maxOf(a.min, b.min); + const max = minOf(a.max, b.max); + const xmin = min !== undefined && ((a.min === min && a.xmin === true) || (b.min === min && b.xmin === true)); + const xmax = max !== undefined && ((a.max === max && a.xmax === true) || (b.max === max && b.xmax === true)); + if (min !== undefined && max !== undefined && (min > max || (min === max && (xmin || xmax)))) { + throw new OmpTypeError("numeric range intersection is unsatisfiable"); + } + if (a.divisor !== undefined && b.divisor !== undefined && a.divisor !== b.divisor) { + return { k: "intersection", members: [a, b] }; + } + return { + k: "number", + int: a.int || b.int, + divisor: a.divisor ?? b.divisor, + min, + max, + xmin, + xmax, + }; + } + if (a.k === "array" && b.k === "array") { + const min = maxOf(a.min, b.min); + const max = minOf(a.max, b.max); + if (min !== undefined && max !== undefined && min > max) { + throw new OmpTypeError("array length intersection is unsatisfiable"); + } + return { k: "array", el: intersect(a.el, b.el), min, max }; + } + if (a.k === "instance" && b.k === "instance") { + if (a.ctor === b.ctor || a.ctor.prototype instanceof b.ctor) return a; + if (b.ctor.prototype instanceof a.ctor) return b; + throw new OmpTypeError(`intersection of ${a.expected} and ${b.expected} is unsatisfiable`); + } + if (a.k === b.k && ["null", "undefined", "boolean", "bigint", "symbol", "anyobject"].includes(a.k)) return a; + const leftDomain = domainOf(a); + const rightDomain = domainOf(b); + if (leftDomain !== undefined && rightDomain !== undefined && leftDomain !== rightDomain) { + throw new OmpTypeError(`intersection of ${leftDomain} and ${rightDomain} is unsatisfiable`); + } + if (a.k === "anyobject" && rightDomain === "object") return b; + if (b.k === "anyobject" && leftDomain === "object") return a; + const members = [...(a.k === "intersection" ? a.members : [a]), ...(b.k === "intersection" ? b.members : [b])]; + return { k: "intersection", members }; +} + +function domainOf(ir: IR): string | undefined { + switch (ir.k) { + case "null": + return "null"; + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "string": + case "number": + return ir.k; + case "array": + case "tuple": + return "array"; + case "object": + case "anyobject": + case "instance": + return "object"; + case "lit": + return ir.v === null ? "null" : Array.isArray(ir.v) ? "array" : typeof ir.v; + case "refine": + return domainOf(ir.base); + case "morph": + return domainOf(ir.input); + case "sub": + return domainOf(ir.schema.ir); + case "alias": + return domainOf(ir.resolve()); + case "union": + case "intersection": { + const first = domainOf(ir.members[0] ?? { k: "never" }); + return ir.members.every(member => domainOf(member) === first) ? first : undefined; + } + default: + return undefined; + } +} +function lowerBoundWithin(source: Extract, target: Extract): boolean { + if (target.min === undefined) return true; + if (source.min === undefined || source.min < target.min) return false; + return source.min !== target.min || target.xmin !== true || source.xmin === true; +} + +function upperBoundWithin(source: Extract, target: Extract): boolean { + if (target.max === undefined) return true; + if (source.max === undefined || source.max > target.max) return false; + return source.max !== target.max || target.xmax !== true || source.xmax === true; +} + +function lengthWithin( + source: Extract, + target: Extract, +): boolean { + return ( + (target.min === undefined || (source.min !== undefined && source.min >= target.min)) && + (target.max === undefined || (source.max !== undefined && source.max <= target.max)) + ); +} + +function isSubtype(source: IR, target: IR, seen = new WeakMap>()): boolean { + if (source === target || target.k === "unknown" || source.k === "never") return true; + let targets = seen.get(source); + if (targets?.has(target)) return true; + if (targets === undefined) { + targets = new Set(); + seen.set(source, targets); + } + targets.add(target); + if (source.k === "alias") return isSubtype(source.resolve(), target, seen); + if (target.k === "alias") return isSubtype(source, target.resolve(), seen); + if (source.k === "union") return source.members.every(member => isSubtype(member, target, seen)); + if (target.k === "union") return target.members.some(member => isSubtype(source, member, seen)); + if (target.k === "intersection") return target.members.every(member => isSubtype(source, member, seen)); + if (source.k === "intersection") return source.members.some(member => isSubtype(member, target, seen)); + if (source.k === "lit") return !(walk(target, source.v) instanceof OmpErrors); + if (source.k === "refine") return isSubtype(source.base, target, seen); + if (source.k === "morph") return isSubtype(source.out ?? source.input, target, seen); + if (source.k === "sub") return isSubtype(source.schema.ir, target, seen); + if (target.k === "refine" || target.k === "morph" || target.k === "sub") return false; + if (source.k === "string" && target.k === "string") { + return lengthWithin(source, target) && (!target.url || source.url === true); + } + if (source.k === "number" && target.k === "number") { + return ( + lowerBoundWithin(source, target) && + upperBoundWithin(source, target) && + (!target.int || source.int === true) && + (target.divisor === undefined || (source.divisor !== undefined && source.divisor % target.divisor === 0)) + ); + } + if (source.k === "array" && target.k === "array") { + return lengthWithin(source, target) && isSubtype(source.el, target.el, seen); + } + if (source.k === "object" && target.k === "object") { + for (const targetProp of target.props) { + const sourceProp = source.props.find(prop => prop.key === targetProp.key); + if (sourceProp === undefined) { + if (!targetProp.opt) return false; + continue; + } + if (!targetProp.opt && sourceProp.opt) return false; + if (!isSubtype(sourceProp.val, targetProp.val, seen)) return false; + } + if (target.extras === "reject") { + if (source.extras !== "reject") return false; + if ( + target.index === undefined && + source.props.some(sourceProp => !target.props.some(targetProp => targetProp.key === sourceProp.key)) + ) { + return false; + } + } + return true; + } + if (source.k === "instance" && target.k === "instance") { + return source.ctor === target.ctor || source.ctor.prototype instanceof target.ctor; + } + if (source.k === "object" && target.k === "anyobject") return true; + if (source.k === "instance" && target.k === "anyobject") return true; + if (source.k === "tuple" && target.k === "array") { + return ( + source.prefix.every(item => isSubtype(item.val, target.el, seen)) && + source.postfix.every(item => isSubtype(item, target.el, seen)) && + (source.variadic === undefined || isSubtype(source.variadic, target.el, seen)) + ); + } + if (source.k !== target.k) return false; + switch (source.k) { + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "anyobject": + return true; + case "tuple": + return target.k === "tuple" && expectedTuple(source) === expectedTuple(target); + case "instance": + return target.k === "instance" && source.ctor === target.ctor; + default: + return false; + } +} + +function expectedTuple(tuple: Extract): string { + return JSON.stringify({ + prefix: tuple.prefix.map(item => [item.opt, item.hasDefault, expectedOf(item.val)]), + variadic: tuple.variadic === undefined ? undefined : expectedOf(tuple.variadic), + postfix: tuple.postfix.map(expectedOf), + }); +} + +function irEquals(left: IR, right: IR): boolean { + return isSubtype(left, right) && isSubtype(right, left); +} + +function maxOf(a: number | undefined, b: number | undefined): number | undefined { + if (a === undefined) return b; + if (b === undefined) return a; + return Math.max(a, b); +} + +function minOf(a: number | undefined, b: number | undefined): number | undefined { + if (a === undefined) return b; + if (b === undefined) return a; + return Math.min(a, b); +} + +function withLengthBound(ir: IR, side: "min" | "max", bound: number): IR { + if (ir.k === "array" || ir.k === "string") { + return side === "min" ? { ...ir, min: bound } : { ...ir, max: bound }; + } + throw new OmpTypeError(`cannot apply length bound to ${ir.k}`); +} +function withNumericBound(ir: IR, side: "min" | "max", bound: number, exclusive = false): IR { + if (ir.k === "number") { + return side === "min" ? { ...ir, min: bound, xmin: exclusive } : { ...ir, max: bound, xmax: exclusive }; + } + throw new OmpTypeError(`cannot apply numeric bound to ${ir.k}`); +} + +/** + * Parse a definition into a callable schema with distinct input/output inference. + */ +export function type(def: def): FluentType, InferDefIn> { + return makeType, InferDefIn>(parseDef(def), EMPTY_STEPS, EMPTY_META); +} + +/** String keyword with a parser that morphs validated text to another output. */ +export interface ParsedStringKeyword extends FluentType { + readonly parse: FluentType; +} + +/** Morphing string keyword paired with its non-morphing preformatted validator. */ +export interface PreformattedKeyword extends FluentType { + readonly preformatted: FluentType; +} + +/** Base64 keyword with its URL-safe alphabet variant. */ +export interface Base64Keyword extends FluentType { + readonly url: FluentType; +} + +/** Date-string keyword family. */ +export interface DateStringKeyword extends ParsedStringKeyword { + readonly iso: ParsedStringKeyword; + readonly epoch: ParsedStringKeyword; +} + +/** IP address keyword family. */ +export interface IpKeyword extends FluentType { + readonly v4: FluentType; + readonly v6: FluentType; +} + +/** UUID keyword family. */ +export interface UuidKeyword extends FluentType { + readonly v1: FluentType; + readonly v2: FluentType; + readonly v3: FluentType; + readonly v4: FluentType; + readonly v5: FluentType; + readonly v6: FluentType; + readonly v7: FluentType; + readonly v8: FluentType; +} + +/** String normalization keyword family. */ +export interface NormalizeKeyword extends PreformattedKeyword { + readonly NFC: PreformattedKeyword; + readonly NFD: PreformattedKeyword; + readonly NFKC: PreformattedKeyword; + readonly NFKD: PreformattedKeyword; +} + +/** Runtime string parsers exposed under `type.parse`. */ +export interface ParseKeyword { + readonly number: FluentType; + readonly integer: FluentType; + readonly json: FluentType; + readonly date: FluentType; + readonly url: FluentType; + readonly boolean: FluentType; + readonly bigint: FluentType; +} + +/** Full string keyword module attached to `type.string`. */ +export interface StringKeyword extends FluentType { + readonly alpha: FluentType; + readonly alphanumeric: FluentType; + readonly base64: Base64Keyword; + readonly capitalize: PreformattedKeyword; + readonly creditCard: FluentType; + readonly date: DateStringKeyword; + readonly digits: FluentType; + readonly email: FluentType; + readonly hex: FluentType; + readonly integer: ParsedStringKeyword; + readonly ip: IpKeyword; + readonly json: ParsedStringKeyword; + readonly lower: PreformattedKeyword; + readonly normalize: NormalizeKeyword; + readonly numeric: ParsedStringKeyword; + readonly regex: FluentType; + readonly semver: FluentType; + readonly trim: PreformattedKeyword; + readonly upper: PreformattedKeyword; + readonly url: ParsedStringKeyword; + readonly uuid: UuidKeyword; +} + +/** Number keyword module attached to `type.number`. */ +export interface NumberKeyword extends FluentType { + readonly integer: FluentType; +} + +type Constructed = ctor extends abstract new (...args: never[]) => infer instance ? instance : never; + +function keywordSchema(name: string): FluentType { + const ir = keywordIR(name); + if (ir === undefined) throw new OmpTypeError(`missing built-in keyword ${name}`); + return makeType(ir, [], {}); +} + +function parsedKeyword(name: string): ParsedStringKeyword { + return Object.assign(keywordSchema(name), { + parse: keywordSchema(`${name}.parse`), + }); +} + +function preformattedKeyword(name: string): PreformattedKeyword { + return Object.assign(keywordSchema(name), { + preformatted: keywordSchema(`${name}.preformatted`), + }); +} + +function caseResolver(value: unknown): (input: unknown) => unknown { + if (typeof value !== "function") throw new OmpTypeError("match case values must be functions"); + return input => Reflect.apply(value, undefined, [input]); +} + +export namespace type { + /** Error aggregate returned by failed validations (`result instanceof type.errors`). */ + export const errors = OmpErrors; + export type errors = OmpErrors; + + const normalize: NormalizeKeyword = Object.assign(keywordSchema("string.normalize"), { + preformatted: keywordSchema("string.normalize.NFC.preformatted"), + NFC: preformattedKeyword("string.normalize.NFC"), + NFD: preformattedKeyword("string.normalize.NFD"), + NFKC: preformattedKeyword("string.normalize.NFKC"), + NFKD: preformattedKeyword("string.normalize.NFKD"), + }); + const base64: Base64Keyword = Object.assign(keywordSchema("string.base64"), { + url: keywordSchema("string.base64.url"), + }); + const date: DateStringKeyword = Object.assign(parsedKeyword("string.date"), { + iso: parsedKeyword("string.date.iso"), + epoch: parsedKeyword("string.date.epoch"), + }); + const ip: IpKeyword = Object.assign(keywordSchema("string.ip"), { + v4: keywordSchema("string.ip.v4"), + v6: keywordSchema("string.ip.v6"), + }); + const uuid: UuidKeyword = Object.assign(keywordSchema("string.uuid"), { + v1: keywordSchema("string.uuid.v1"), + v2: keywordSchema("string.uuid.v2"), + v3: keywordSchema("string.uuid.v3"), + v4: keywordSchema("string.uuid.v4"), + v5: keywordSchema("string.uuid.v5"), + v6: keywordSchema("string.uuid.v6"), + v7: keywordSchema("string.uuid.v7"), + v8: keywordSchema("string.uuid.v8"), + }); + + /** String validator and its refinement/morph keyword module. */ + export const string: StringKeyword = Object.assign(makeType({ k: "string" }, [], {}), { + alpha: keywordSchema("string.alpha"), + alphanumeric: keywordSchema("string.alphanumeric"), + base64, + capitalize: preformattedKeyword("string.capitalize"), + creditCard: keywordSchema("string.creditCard"), + date, + digits: keywordSchema("string.digits"), + email: keywordSchema("string.email"), + hex: keywordSchema("string.hex"), + integer: parsedKeyword("string.integer"), + ip, + json: parsedKeyword("string.json"), + lower: preformattedKeyword("string.lower"), + normalize, + numeric: parsedKeyword("string.numeric"), + regex: keywordSchema("string.regex"), + semver: keywordSchema("string.semver"), + trim: preformattedKeyword("string.trim"), + upper: preformattedKeyword("string.upper"), + url: parsedKeyword("string.url"), + uuid, + }); + + /** Runtime parser keyword family. */ + export const parse: ParseKeyword = { + number: keywordSchema("parse.number"), + integer: keywordSchema("parse.integer"), + json: keywordSchema("parse.json"), + date: keywordSchema("parse.date"), + url: keywordSchema("parse.url"), + boolean: keywordSchema("parse.boolean"), + bigint: keywordSchema("parse.bigint"), + }; + + /** Number validator with integer refinement. */ + export const number: NumberKeyword = Object.assign(makeType({ k: "number" }, [], {}), { + integer: makeType({ k: "number", int: true }, [], {}), + }); + + /** Boolean validator. */ + export const boolean = makeType({ k: "boolean" }, [], {}); + /** Bigint validator. */ + export const bigint = makeType({ k: "bigint" }, [], {}); + /** Symbol validator. */ + export const symbol = makeType({ k: "symbol" }, [], {}); + /** Non-null object validator. */ + export const object = makeType({ k: "anyobject" }, [], {}); + /** Unknown validator. */ + export const unknown = makeType({ k: "unknown" }, [], {}); + /** Alias of the unknown validator. */ + export const any = unknown; + /** Validator that rejects every value. */ + export const never = makeType({ k: "never" }, [], {}); + /** Date instance validator. */ + // biome-ignore lint/suspicious/noShadowRestrictedNames: ArkType exposes this exact keyword. + export const Date = makeType({ k: "instance", ctor: globalThis.Date, expected: "a Date" }, [], {}); + + /** Validate instances of `ctor`. */ + export function instanceOf(ctor: ctor): FluentType> { + const name = Reflect.get(ctor, "name"); + const expected = typeof name === "string" && name.length > 0 ? `an instance of ${name}` : "an instance"; + return makeType>({ k: "instance", ctor, expected }, [], {}); + } + + /** Validate one exact unit value. */ + export function unit(value: value): FluentType { + return makeType({ k: "lit", v: value }, [], {}); + } + + /** Union of literal values from a runtime array. */ + export function enumerated(...values: values): FluentType { + const members: IR[] = values.map(value => ({ k: "lit", v: value })); + const ir: IR = + members.length === 0 ? { k: "never" } : members.length === 1 ? members[0] : { k: "union", members }; + return makeType(ir, [], {}); + } + + /** Build a first-match dispatcher from schema-expression keys and a `default` case. */ + export function match>(cases: cases): (value: unknown) => unknown { + const branches: { schema: BaseType; resolve: (value: unknown) => unknown }[] = []; + let fallback: ((value: unknown) => unknown) | undefined; + for (const definition in cases) { + const resolver = caseResolver(cases[definition]); + if (definition === "default") fallback = resolver; + else branches.push({ schema: raw(definition), resolve: resolver }); + } + return value => { + for (const branch of branches) { + if (branch.schema.allows(value)) return branch.resolve(value); + } + if (fallback !== undefined) return fallback(value); + throw new OmpTypeError("match requires a matching case or default"); + }; + } + /** Preserve a definition's literal type while authoring reusable modules. */ + export function define(definition: definition): definition { + return definition; + } + + /** Build a lazy named scope from aliases and recursive definitions. */ + export function scope(aliases: Record, options?: ScopeOptions): TypeScope { + return buildScope(aliases, options); + } + + /** Compile a named schema module whose definitions may reference each other. */ + export function module>( + definitions: definitions, + ): { [name in keyof definitions]: Type, InferDefIn> } { + return scope(definitions).export() as unknown as { + [name in keyof definitions]: Type, InferDefIn>; + }; + } + + /** Build a runtime generic whose parameter names are supplied as `""`. */ + export function generic( + parameters: string, + definition: definition, + ): (...arguments_: readonly unknown[]) => BaseType { + const names = parameters + .replace(/^<|>$/g, "") + .split(",") + .map(name => name.trim()) + .filter(Boolean); + return (...arguments_) => { + if (arguments_.length !== names.length) { + throw new OmpTypeError(`generic expects ${names.length} arguments (received ${arguments_.length})`); + } + const aliases: Record = {}; + for (let index = 0; index < names.length; index++) aliases[names[index]] = arguments_[index]; + return scope(aliases).type(definition) as BaseType; + }; + } + + /** Untyped builder for runtime-assembled definitions. */ + export function raw(def: unknown): BaseType { + return makeType(parseDef(def), [], {}) as unknown as BaseType; + } +} + +// Reserved words cannot be declared as namespace bindings, but ArkType exposes +// them as runtime keyword properties. +Object.assign(type, { + null: makeType({ k: "null" }, [], {}), + undefined: makeType({ k: "undefined" }, [], {}), + true: makeType({ k: "lit", v: true }, [], {}), + false: makeType({ k: "lit", v: false }, [], {}), +}); + +export interface ScopeOptions { + jitless?: boolean; +} + +/** Callable builder bound to one alias scope. */ +export type ScopedBuilder = ( + definition: definition, +) => FluentType, InferDefIn>; + +/** Named schema scope with a scoped builder and compiled module export. */ +export interface TypeScope { + readonly type: ScopedBuilder; + export(): Record; +} + +/** Build a scope whose aliases resolve lazily, including recursive cycles. */ +export function scope(aliases: Record, options?: ScopeOptions): TypeScope { + return buildScope(aliases, options); +} + +function buildScope(aliases: Record, _options?: ScopeOptions): TypeScope { + const references = new Map(); + const targets = new Map(); + const resolve: AliasResolver = name => { + if (!Object.hasOwn(aliases, name)) return undefined; + const existing = references.get(name); + if (existing !== undefined) return existing; + const reference: IR = { + k: "alias", + name, + resolve: () => { + const target = targets.get(name); + if (target !== undefined) return target; + const parsed = parseDef(aliases[name], resolve); + targets.set(name, parsed); + return parsed; + }, + }; + references.set(name, reference); + return reference; + }; + const scoped = Object.assign( + (definition: unknown) => makeType(parseDef(definition, resolve), [], {}), + type, + ) as unknown as ScopedBuilder; + return { + type: scoped, + export() { + const schemas: Record = {}; + for (const name in aliases) schemas[name] = scoped(name) as unknown as BaseType; + return schemas; + }, + }; +} + +/** A schema whose output type is not statically known (`type.raw` results). */ +export type BaseType = Type; + +/** `hasMorph` re-export for diagnostics/tooling. */ +export { hasMorph }; diff --git a/packages/omptype/src/typebox.ts b/packages/omptype/src/typebox.ts new file mode 100644 index 000000000..dc1487678 --- /dev/null +++ b/packages/omptype/src/typebox.ts @@ -0,0 +1,487 @@ +import { OmpTypeError } from "./errors"; +import type { Def } from "./ir"; +import { type NarrowContext, type Type as OmpType, type } from "./type"; + +export interface Meta { + title?: string; + description?: string; + default?: unknown; + examples?: unknown[]; + [key: string]: unknown; +} + +export interface StringOpts extends Meta { + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; +} + +export interface NumberOpts extends Meta { + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; +} + +export interface ArrayOpts extends Meta { + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; +} + +export interface ObjectOpts extends Meta { + additionalProperties?: boolean | TSchema; +} +const OPTIONAL_INNER = Symbol("omptype.typebox.optionalInner"); +const OBJECT_INFO = Symbol("omptype.typebox.objectInfo"); + +export interface TypeBoxValidationFailure { + message: string; +} + +export type TypeBoxSafeParseResult = + | { success: true; data: T } + | { success: false; error: TypeBoxValidationFailure }; + +interface LegacyTypeBoxCompat { + /** TypeBox compatibility validator used by legacy extension loaders. */ + __validator(data: unknown): T | TypeBoxValidationFailure; + /** Zod-style compatibility parser used by legacy extensions. */ + safeParse(input: unknown): TypeBoxSafeParseResult; +} + +export type TSchema = OmpType & LegacyTypeBoxCompat; +export type Static = T["infer"]; +export type TAny = TSchema; +export type TUnknown = TSchema; +export type TNever = TSchema; +export type TNull = TSchema; +export type TString = TSchema; +export type TNumber = TSchema; +export type TInteger = TSchema; +export type TBoolean = TSchema; +export type TLiteral = TSchema; +export type TArray = TSchema[]>; +export type TTuple = TSchema<{ + -readonly [K in keyof E]: Static; +}>; +export type TOptional = TSchema | undefined> & { readonly [OPTIONAL_INNER]: E }; +export type TUnion = TSchema>; +export type TIntersect = TSchema< + UnionToIntersection> +>; +export type TEnum = TSchema; +export type TRecord = TSchema, PropertyKey>, Static>>; +export type TNullable = TSchema | null>; +export type TReadonly = TSchema>>; +export type TUnsafe = TSchema; + +type OptionalKeys

> = { + [K in keyof P]-?: P[K] extends { readonly [OPTIONAL_INNER]: TSchema } ? K : never; +}[keyof P]; +type RequiredKeys

> = Exclude>; +type ObjectStatic

> = { + [K in RequiredKeys

]: Static; +} & { + [K in OptionalKeys

]?: Exclude, undefined>; +}; +export type TObject

= Record> = TSchema>; +type RequiredProps

> = { + [K in keyof P]: P[K] extends TOptional ? E : P[K]; +}; + +interface RuntimeType extends OmpType { + [OPTIONAL_INNER]?: TSchema; + [OBJECT_INFO]?: ObjectInfo; + describe(description: string): RuntimeType; + default(value: T | (() => T)): RuntimeType; + or(schema: OmpType): RuntimeType; + and(schema: OmpType): RuntimeType; + array(): RuntimeType; + atLeastLength(bound: number): RuntimeType; + atMostLength(bound: number): RuntimeType; + atLeast(bound: number): RuntimeType; + atMost(bound: number): RuntimeType; + narrow(predicate: (value: T, ctx: NarrowContext) => value is N): RuntimeType; + narrow(predicate: (value: T, ctx: NarrowContext) => boolean): RuntimeType; +} +type CompatRuntime = RuntimeType & LegacyTypeBoxCompat; + +type ObjectInfo = { props: Record; additionalProperties?: boolean | TSchema }; + +function validationFailure(message: string): TypeBoxValidationFailure { + return { message }; +} + +function withLegacyCompat(schema: OmpType): CompatRuntime { + const compatSchema = schema as unknown as CompatRuntime; + if (!Object.hasOwn(compatSchema, "__validator")) { + Object.defineProperty(compatSchema, "__validator", { + value: (data: unknown): T | TypeBoxValidationFailure => { + const result = schema(data); + return result instanceof type.errors ? validationFailure(result.summary) : result; + }, + configurable: true, + }); + } + if (!Object.hasOwn(compatSchema, "safeParse")) { + Object.defineProperty(compatSchema, "safeParse", { + value: (input: unknown): TypeBoxSafeParseResult => { + const result = schema(input); + return result instanceof type.errors + ? { success: false, error: validationFailure(result.summary) } + : { success: true, data: result }; + }, + configurable: true, + }); + } + return compatSchema; +} + +function applyMeta(schema: RuntimeType, opts?: Meta): CompatRuntime { + let result = schema; + const description = opts?.description ?? opts?.title; + if (description !== undefined) result = result.describe(description); + if (opts && Object.hasOwn(opts, "default")) result = result.default(opts.default as T); + return withLegacyCompat(result); +} + +function withJsonSchemaKeywords(schema: CompatRuntime, keywords: Record): CompatRuntime { + const emitBase = schema.toJsonSchema; + schema.toJsonSchema = options => ({ ...emitBase(options), ...keywords }); + return schema; +} + +function checkFiniteOption(name: string, value: number | undefined): void { + if (value !== undefined && !Number.isFinite(value)) throw new OmpTypeError(`${name} must be finite`); +} + +function tString(opts?: StringOpts): TString { + checkFiniteOption("minLength", opts?.minLength); + checkFiniteOption("maxLength", opts?.maxLength); + let schema = type.raw( + opts?.format === "url" || opts?.format === "uri" ? "string.url" : "string", + ) as RuntimeType; + if (opts?.minLength !== undefined) schema = schema.atLeastLength(opts.minLength); + if (opts?.maxLength !== undefined) schema = schema.atMostLength(opts.maxLength); + if (opts?.pattern !== undefined) { + let regex: RegExp; + try { + regex = new RegExp(opts.pattern); + } catch { + throw new OmpTypeError(`invalid regular expression pattern ${JSON.stringify(opts.pattern)}`); + } + schema = schema.narrow((value, ctx) => regex.test(value) || ctx.mustBe(`a string matching ${opts.pattern}`)); + } + if (opts?.format !== undefined && opts.format !== "url" && opts.format !== "uri") { + const format = opts.format; + const valid = formatPredicate(format); + schema = schema.narrow((value, ctx) => valid(value) || ctx.mustBe(`a string in ${format} format`)); + } + return applyMeta(schema, opts); +} + +function formatPredicate(format: string): (value: string) => boolean { + switch (format) { + case "url": + case "uri": + return value => { + try { + new URL(value); + return true; + } catch { + return false; + } + }; + case "email": + return value => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + case "uuid": + return value => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); + case "date-time": + return value => + /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value) && + !Number.isNaN(Date.parse(value)); + case "date": + return value => /^\d{4}-\d\d-\d\d$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)); + default: + return () => true; + } +} + +function tNumber(opts?: NumberOpts, integer = false): TNumber { + for (const key of ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"] as const) { + checkFiniteOption(key, opts?.[key]); + } + if (opts?.multipleOf !== undefined && opts.multipleOf <= 0) + throw new OmpTypeError("multipleOf must be greater than zero"); + let lower: { value: number; exclusive: boolean } | undefined; + if (opts?.minimum !== undefined) lower = { value: opts.minimum, exclusive: false }; + if (opts?.exclusiveMinimum !== undefined && (!lower || opts.exclusiveMinimum >= lower.value)) { + lower = { value: opts.exclusiveMinimum, exclusive: true }; + } + let upper: { value: number; exclusive: boolean } | undefined; + if (opts?.maximum !== undefined) upper = { value: opts.maximum, exclusive: false }; + if (opts?.exclusiveMaximum !== undefined && (!upper || opts.exclusiveMaximum <= upper.value)) { + upper = { value: opts.exclusiveMaximum, exclusive: true }; + } + const keyword = integer ? "number.integer" : "number"; + const lowerDsl = lower ? `${lower.value} ${lower.exclusive ? "<" : "<="} ` : ""; + const upperDsl = upper ? ` ${upper.exclusive ? "<" : "<="} ${upper.value}` : ""; + let schema = type.raw(`${lowerDsl}${keyword}${upperDsl}`) as RuntimeType; + if (opts?.multipleOf !== undefined) { + const divisor = opts.multipleOf; + schema = schema.narrow((value, ctx) => { + const quotient = value / divisor; + return ( + Math.abs(quotient - Math.round(quotient)) <= Number.EPSILON * Math.max(1, Math.abs(quotient)) || + ctx.mustBe(`a multiple of ${divisor}`) + ); + }); + } + return applyMeta(schema, opts); +} + +function tLiteral(value: V, opts?: Meta): TLiteral { + return applyMeta(type.enumerated(value) as RuntimeType, opts); +} + +function tNever(opts?: Meta): TNever { + return applyMeta( + (type.raw("unknown") as RuntimeType).narrow((_value, ctx): _value is never => ctx.mustBe("never")), + opts, + ); +} + +function tUnion(schemas: E, opts?: Meta): TUnion { + if (schemas.length === 0) return tNever(opts) as TUnion; + let result = schemas[0] as unknown as RuntimeType; + for (let i = 1; i < schemas.length; i++) result = result.or(schemas[i]); + return applyMeta(result, opts) as TUnion; +} + +function tIntersect( + schemas: E, + opts?: Meta, +): TSchema>> { + if (schemas.length === 0) + return applyMeta(type.raw("unknown") as RuntimeType, opts) as TSchema< + UnionToIntersection> + >; + const validateAll = (): RuntimeType>> => { + const base = type.raw("unknown") as RuntimeType; + return base.narrow((value, ctx): value is UnionToIntersection> => { + for (const schema of schemas) { + if (schema(value) instanceof type.errors) return ctx.mustBe("a value satisfying every intersection member"); + } + return true; + }); + }; + if (schemas.some(schema => schema.hasSteps)) return applyMeta(validateAll(), opts); + let result = schemas[0] as unknown as RuntimeType; + try { + for (let i = 1; i < schemas.length; i++) result = result.and(schemas[i]); + } catch (error) { + if (error instanceof OmpTypeError) return applyMeta(validateAll(), opts); + throw error; + } + return applyMeta(result, opts) as TSchema>>; +} +type UnionToIntersection = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void + ? I + : never; + +function enumValues(values: Record | readonly (string | number)[]): (string | number)[] { + if (Array.isArray(values)) return [...values]; + const result: (string | number)[] = []; + const record = values as Record; + for (const key in record) { + const value = record[key]; + if (!(/^\d+$/.test(key) && typeof value === "string") && !result.includes(value)) result.push(value); + } + return result; +} + +function tEnum | readonly (string | number)[]>( + values: E, + opts?: Meta, +): TSchema { + return applyMeta(type.enumerated(...enumValues(values)) as RuntimeType, opts) as unknown as TSchema< + E extends readonly (infer V)[] ? V : E[keyof E] + >; +} + +function tArray(item: E, opts?: ArrayOpts): TArray { + checkFiniteOption("minItems", opts?.minItems); + checkFiniteOption("maxItems", opts?.maxItems); + let schema = (item as unknown as RuntimeType>).array(); + if (opts?.minItems !== undefined) schema = schema.atLeastLength(opts.minItems); + if (opts?.maxItems !== undefined) schema = schema.atMostLength(opts.maxItems); + if (opts?.uniqueItems) { + schema = schema.narrow((values, ctx) => { + for (let i = 0; i < values.length; i++) { + for (let j = i + 1; j < values.length; j++) { + if (jsonEqual(values[i], values[j])) return ctx.mustBe("an array with unique items"); + } + } + return true; + }); + } + const result = applyMeta(schema, opts); + return opts?.uniqueItems ? withJsonSchemaKeywords(result, { uniqueItems: true }) : result; +} + +function jsonEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) return false; + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} + +function tTuple(items: E, opts?: Meta): TTuple { + const schema = (type.raw("unknown") as RuntimeType).narrow( + (value, ctx): value is { -readonly [K in keyof E]: Static } => { + if (!Array.isArray(value) || value.length !== items.length) + return ctx.mustBe(`a tuple of length ${items.length}`); + for (let i = 0; i < items.length; i++) + if (items[i](value[i]) instanceof type.errors) return ctx.mustBe(`a valid item at index ${i}`); + return true; + }, + ); + return applyMeta(schema, opts); +} + +function tObject>(properties: P, opts?: ObjectOpts): TObject

{ + const def: Record = {}; + const props: Record = {}; + for (const key in properties) { + const schema = properties[key]; + const inner = (schema as unknown as RuntimeType)[OPTIONAL_INNER]; + def[inner ? `${key}?` : key] = (inner ?? schema) as Def; + props[key] = schema; + } + if (opts?.additionalProperties === false) def["+"] = "reject"; + else if (opts?.additionalProperties && opts.additionalProperties !== true) + def["[string]"] = opts.additionalProperties as Def; + const schema = applyMeta(type.raw(def) as RuntimeType>, opts); + schema[OBJECT_INFO] = { props, additionalProperties: opts?.additionalProperties }; + return schema; +} + +function tRecord(key: K, value: V, opts?: Meta): TRecord { + const base = (type.raw({ "[string]": value }) as RuntimeType>>).narrow((record, ctx) => { + for (const name in record) + if (key(name) instanceof type.errors) return ctx.mustBe("an object with valid record keys"); + return true; + }); + return applyMeta(base, opts) as TRecord; +} + +function tOptional(schema: E, opts?: Meta): TOptional { + const marker = applyMeta( + (schema as unknown as RuntimeType>).or(type.raw("undefined")), + opts, + ) as RuntimeType | undefined>; + marker[OPTIONAL_INNER] = schema; + return marker as unknown as TOptional; +} + +function tNullable(schema: E, opts?: Meta): TSchema | null> { + return applyMeta((schema as unknown as RuntimeType>).or(type.raw("null")), opts); +} + +function requireObject(schema: TSchema, operation: string): ObjectInfo { + const info = (schema as unknown as RuntimeType)[OBJECT_INFO]; + if (!info) throw new OmpTypeError(`Type.${operation} requires a schema created by Type.Object`); + return info; +} + +function tPartial

>(schema: TObject

): TSchema>> { + const info = requireObject(schema, "Partial"); + const props: Record = {}; + for (const key in info.props) + props[key] = (info.props[key] as unknown as RuntimeType)[OPTIONAL_INNER] + ? info.props[key] + : tOptional(info.props[key]); + return tObject(props, { additionalProperties: info.additionalProperties }) as TSchema>>; +} + +function tRequired

>(schema: TObject

): TObject> { + const info = requireObject(schema, "Required"); + const props: Record = {}; + for (const key in info.props) { + props[key] = (info.props[key] as unknown as RuntimeType)[OPTIONAL_INNER] ?? info.props[key]; + } + return tObject(props, { additionalProperties: info.additionalProperties }) as TObject>; +} + +function tPick

, const K extends readonly (keyof P)[]>( + schema: TObject

, + keys: K, +): TObject> { + const info = requireObject(schema, "Pick"); + const props: Record = {}; + for (const key of keys) if (typeof key === "string" && info.props[key]) props[key] = info.props[key]; + return tObject(props, { additionalProperties: info.additionalProperties }) as TObject>; +} + +function tOmit

, const K extends readonly (keyof P)[]>( + schema: TObject

, + keys: K, +): TObject> { + const info = requireObject(schema, "Omit"); + const omitted = new Set(keys); + const props: Record = {}; + for (const key in info.props) if (!omitted.has(key)) props[key] = info.props[key]; + return tObject(props, { additionalProperties: info.additionalProperties }) as TObject>; +} + +function tComposite>[]>( + schemas: E, + opts?: ObjectOpts, +): TSchema>> { + const props: Record = {}; + for (const schema of schemas) Object.assign(props, requireObject(schema, "Composite").props); + return tObject(props, opts) as TSchema>>; +} + +function tUnsafe(_jsonSchema: Record = {}): TUnsafe { + // Raw JSON Schema is accepted for source compatibility but is not retained or validated: + // omptype cannot honestly implement that contract without importing a second validator. + return withLegacyCompat(type.unknown as OmpType); +} + +export const Type = { + String: tString, + Number: (opts?: NumberOpts) => tNumber(opts), + Integer: (opts?: NumberOpts) => tNumber(opts, true), + Boolean: (opts?: Meta) => applyMeta(type.raw("boolean") as RuntimeType, opts), + Null: (opts?: Meta) => applyMeta(type.raw("null") as RuntimeType, opts), + Any: (opts?: Meta) => applyMeta(type.raw("unknown") as RuntimeType, opts), + Unknown: (opts?: Meta) => applyMeta(type.raw("unknown") as RuntimeType, opts), + Never: tNever, + Literal: tLiteral, + Union: tUnion, + Intersect: tIntersect, + Enum: tEnum, + Array: tArray, + Tuple: tTuple, + Object: tObject, + Record: tRecord, + Optional: tOptional, + Nullable: tNullable, + Readonly: (schema: E): E => withLegacyCompat(schema) as unknown as E, + Partial: tPartial, + Required: tRequired, + Pick: tPick, + Omit: tOmit, + Composite: tComposite, + Unsafe: tUnsafe, +} as const; + +export type TypeBuilder = typeof Type; +export default { Type }; diff --git a/packages/omptype/src/zod.ts b/packages/omptype/src/zod.ts new file mode 100644 index 000000000..e5ad78d37 --- /dev/null +++ b/packages/omptype/src/zod.ts @@ -0,0 +1,352 @@ +import { type OmpErrors, OmpTypeError } from "./errors"; +import { type EmbeddableSchema, embed, type IR, IR_BRAND, type PropIR } from "./ir"; +import { type NarrowContext, type Type, type } from "./type"; + +interface OptionalSchemaMarker { + readonly _optional: true; +} + +interface RefineOptions { + message?: string; + error?: string; +} + +interface Decoratable extends EmbeddableSchema { + (value: unknown): Out | OmpErrors; + narrow(predicate: (value: Out, context: NarrowContext) => unknown): Decoratable; + pipe(transform: (value: Out, context: NarrowContext) => Next): Decoratable>; + or(def: unknown): Decoratable; + describe(description: string): Decoratable; + default(value: Out | (() => Out)): Decoratable; +} + +export interface ZodLikeIssue { + path: PropertyKey[]; + message: string; +} + +export type ZodLikeSafeParseResult = + | { success: true; data: Out } + | { success: false; error: { message: string; issues: ZodLikeIssue[] } }; + +/** A callable omptype schema carrying the Zod-v4-style fluent surface. */ +export interface ZodLikeSchema extends Type { + readonly _output: Out; + /** @internal Used while composing object property IR. */ + readonly isOptional: boolean; + parse(value: unknown): Out; + safeParse(value: unknown): ZodLikeSafeParseResult; + min(bound: number): ZodLikeSchema; + max(bound: number): ZodLikeSchema; + int(): ZodLikeSchema; + positive(): ZodLikeSchema; + nonnegative(): ZodLikeSchema; + regex(expression: RegExp, message?: string): ZodLikeSchema; + url(): ZodLikeSchema; + optional(): ZodLikeSchema & OptionalSchemaMarker; + nullable(): ZodLikeSchema; + default(value: Exclude | (() => Exclude)): ZodLikeSchema>; + describe(description: string): ZodLikeSchema; + refine(predicate: (value: Out) => unknown, messageOrOptions?: string | RefineOptions): ZodLikeSchema; + transform(transformer: (value: Out) => Next): ZodLikeSchema; + catch(fallback: Out | (() => Out)): ZodLikeSchema; + strict(): ZodLikeSchema; + passthrough(): ZodLikeSchema>; + strip(): ZodLikeSchema; + partial(): Out extends object ? ZodLikeSchema> : ZodLikeSchema; +} + +function schemaFromIR(ir: IR): Decoratable { + const embedded: EmbeddableSchema = { + [IR_BRAND]: true, + ir, + hasSteps: false, + hasDefault: false, + run: value => value, + }; + return type.raw(embedded) as unknown as Decoratable; +} + +function restrictBase(source: Decoratable, ir: IR): Decoratable { + let next = source.hasSteps + ? schemaFromIR({ k: "morph", input: ir, fn: value => source(value) }) + : schemaFromIR(ir); + if (source.description !== undefined) next = next.describe(source.description); + if (source.hasDefault) next = next.default(source.defaultValue as Out | (() => Out)); + return next; +} + +function lengthBound(kind: "min" | "max", schema: Decoratable, bound: number): void { + if (schema.ir.k !== "string" && schema.ir.k !== "array") return; + if (!Number.isSafeInteger(bound) || bound < 0) { + throw new OmpTypeError(`${kind} length must be a nonnegative safe integer`); + } +} + +function refinementMessage(messageOrOptions: string | RefineOptions | undefined): string { + if (typeof messageOrOptions === "string") return messageOrOptions; + return messageOrOptions?.message ?? messageOrOptions?.error ?? "valid (refinement failed)"; +} + +function isStringKeyIR(ir: IR): boolean { + switch (ir.k) { + case "string": + return true; + case "lit": + return typeof ir.v === "string"; + case "union": + return ir.members.length > 0 && ir.members.every(isStringKeyIR); + case "sub": + return isStringKeyIR(ir.schema.ir); + default: + return false; + } +} + +function decorate(schema: Decoratable, optional = false): ZodLikeSchema { + const next = (inner: Decoratable, nextOptional = optional): ZodLikeSchema => decorate(inner, nextOptional); + const withObjectExtras = (extras: "keep" | "reject" | "delete"): ZodLikeSchema => { + if (schema.ir.k !== "object") throw new OmpTypeError("object mode requires an object schema"); + return next(restrictBase(schema, { ...schema.ir, extras })); + }; + Object.defineProperty(schema, "isOptional", { value: optional, enumerable: false }); + + return Object.assign(schema, { + parse(value: unknown): Out { + const result = schema(value); + if (result instanceof type.errors) throw new Error(result.summary); + return result; + }, + safeParse(value: unknown): ZodLikeSafeParseResult { + const result = schema(value); + if (!(result instanceof type.errors)) return { success: true, data: result }; + return { + success: false, + error: { + message: result.summary, + issues: result.map(issue => ({ path: [...issue.path], message: issue.problem })), + }, + }; + }, + min(bound: number): ZodLikeSchema { + const ir = schema.ir; + if (ir.k === "string" || ir.k === "array") { + lengthBound("min", schema, bound); + const min = ir.min === undefined ? bound : Math.max(ir.min, bound); + return next(restrictBase(schema, { ...ir, min })); + } + if (ir.k === "number") { + if (Number.isNaN(bound)) throw new OmpTypeError("number min must not be NaN"); + if (ir.min !== undefined && ir.min >= bound) return next(restrictBase(schema, ir)); + return next(restrictBase(schema, { ...ir, min: bound, xmin: false })); + } + throw new OmpTypeError(`cannot apply min to ${ir.k}`); + }, + max(bound: number): ZodLikeSchema { + const ir = schema.ir; + if (ir.k === "string" || ir.k === "array") { + lengthBound("max", schema, bound); + const max = ir.max === undefined ? bound : Math.min(ir.max, bound); + return next(restrictBase(schema, { ...ir, max })); + } + if (ir.k === "number") { + if (Number.isNaN(bound)) throw new OmpTypeError("number max must not be NaN"); + if (ir.max !== undefined && ir.max <= bound) return next(restrictBase(schema, ir)); + return next(restrictBase(schema, { ...ir, max: bound, xmax: false })); + } + throw new OmpTypeError(`cannot apply max to ${ir.k}`); + }, + int(): ZodLikeSchema { + if (schema.ir.k !== "number") throw new OmpTypeError(`cannot apply int to ${schema.ir.k}`); + return next(restrictBase(schema, { ...schema.ir, int: true })); + }, + positive(): ZodLikeSchema { + if (schema.ir.k !== "number") throw new OmpTypeError(`cannot apply positive to ${schema.ir.k}`); + const ir = schema.ir; + if (ir.min !== undefined && ir.min > 0) return next(restrictBase(schema, ir)); + return next(restrictBase(schema, { ...ir, min: 0, xmin: true })); + }, + nonnegative(): ZodLikeSchema { + if (schema.ir.k !== "number") throw new OmpTypeError(`cannot apply nonnegative to ${schema.ir.k}`); + return this.min(0); + }, + regex(expression: RegExp, message?: string): ZodLikeSchema { + if (schema.ir.k !== "string") throw new OmpTypeError(`cannot apply regex to ${schema.ir.k}`); + const expectation = message ?? `matching ${expression}`; + const narrowed = schema.narrow((value, ctx) => { + expression.lastIndex = 0; + const matches = expression.test(value as string); + expression.lastIndex = 0; + return matches || ctx.mustBe(expectation); + }); + return next(narrowed); + }, + url(): ZodLikeSchema { + if (schema.ir.k !== "string") throw new OmpTypeError(`cannot apply url to ${schema.ir.k}`); + return next(restrictBase(schema, { ...schema.ir, url: true })); + }, + optional(): ZodLikeSchema & OptionalSchemaMarker { + const widened = schema.or(type.raw("undefined")) as Decoratable; + return decorate(widened, true) as ZodLikeSchema & OptionalSchemaMarker; + }, + nullable(): ZodLikeSchema { + return decorate(schema.or(type.raw("null")) as Decoratable, optional); + }, + default( + value: Exclude | (() => Exclude), + ): ZodLikeSchema> { + type DefaultOut = Exclude; + const widened = schema.or(type.raw("undefined")) as Decoratable; + const piped = widened.pipe(output => { + if (output !== undefined) return output as DefaultOut; + return typeof value === "function" ? (value as () => DefaultOut)() : value; + }) as Decoratable; + return decorate(piped.default(value as DefaultOut | (() => DefaultOut))); + }, + describe(description: string): ZodLikeSchema { + return next(restrictBase(schema, { ...schema.ir, desc: description }).describe(description)); + }, + refine(predicate: (value: Out) => unknown, messageOrOptions?: string | RefineOptions): ZodLikeSchema { + const expectation = refinementMessage(messageOrOptions); + return next(schema.narrow((value, ctx) => Boolean(predicate(value)) || ctx.mustBe(expectation))); + }, + transform(transformer: (value: Out) => Next): ZodLikeSchema { + return decorate( + schema.pipe(value => transformer(value)), + optional, + ); + }, + catch(fallback: Out | (() => Out)): ZodLikeSchema { + const caught = type.unknown.pipe(input => { + try { + const result = schema(input); + if (!(result instanceof type.errors)) return result; + } catch { + // A caught schema is deliberately total, including user refinement/transform exceptions. + } + return typeof fallback === "function" ? (fallback as () => Out)() : fallback; + }); + return decorate(caught as Decoratable, optional); + }, + strict(): ZodLikeSchema { + return withObjectExtras("reject"); + }, + passthrough(): ZodLikeSchema> { + return withObjectExtras("keep") as ZodLikeSchema>; + }, + strip(): ZodLikeSchema { + return withObjectExtras("delete"); + }, + partial(): Out extends object ? ZodLikeSchema> : ZodLikeSchema { + if (schema.ir.k !== "object") throw new OmpTypeError(`cannot apply partial to ${schema.ir.k}`); + const props = schema.ir.props.map(prop => ({ ...prop, opt: true })); + return next(restrictBase(schema, { ...schema.ir, props })) as Out extends object + ? ZodLikeSchema> + : ZodLikeSchema; + }, + }) as unknown as ZodLikeSchema; +} + +function decorateUnknown(schema: Decoratable): ZodLikeSchema { + return decorate(schema); +} + +export type infer = T extends { readonly _output: infer Out } ? Out : never; + +type SchemaOutput = Schema extends { readonly _output: infer Out } ? Out : never; +type Shape = Readonly>>; +type ObjectOutput = { + -readonly [K in keyof S as S[K] extends OptionalSchemaMarker ? never : K]: SchemaOutput; +} & { + -readonly [K in keyof S as S[K] extends OptionalSchemaMarker ? K : never]?: SchemaOutput; +}; +type Simplify = { [K in keyof T]: T[K] }; +type UnionOutput[]> = SchemaOutput; + +function objectSchema(shape: S): ZodLikeSchema> { + const props: PropIR[] = []; + for (const key in shape) { + const member = shape[key]; + const prop: PropIR = { key, opt: member.isOptional, val: embed(member) }; + if (member.hasDefault) { + prop.hasDefault = true; + prop.def = member.defaultValue; + prop.defFactory = typeof member.defaultValue === "function"; + } + props.push(prop); + } + return decorateUnknown(schemaFromIR({ k: "object", props, extras: "delete" })) as unknown as ZodLikeSchema< + ObjectOutput + >; +} + +export const string = (): ZodLikeSchema => decorate(schemaFromIR(type.string.ir)); +export const number = (): ZodLikeSchema => decorate(schemaFromIR(type.number.ir)); +export const boolean = (): ZodLikeSchema => decorate(schemaFromIR(type.boolean.ir)); +export const literal = (value: Value): ZodLikeSchema => + decorate(schemaFromIR(type.enumerated(value).ir)); +const enumSchema = ( + values: Values, +): ZodLikeSchema => { + if (values.length === 0) throw new OmpTypeError("enum requires at least one value"); + return decorate(schemaFromIR(type.enumerated(...values).ir)); +}; + +export { enumSchema as enum }; +export const union = < + const Schemas extends readonly [ZodLikeSchema, ZodLikeSchema, ...ZodLikeSchema[]], +>( + schemas: Schemas, +): ZodLikeSchema> => + decorate(schemaFromIR({ k: "union", members: schemas.map(schema => embed(schema)) })); +export const array = (element: ZodLikeSchema): ZodLikeSchema => + decorate(schemaFromIR({ k: "array", el: embed(element) })); +export const object = (shape: S): ZodLikeSchema>> => + objectSchema(shape); +export const record = ( + keySchema: ZodLikeSchema, + valueSchema: ZodLikeSchema, +): ZodLikeSchema> => { + if (!isStringKeyIR(keySchema.ir)) throw new OmpTypeError("record keys must use a string schema"); + const base = schemaFromIR>({ + k: "object", + props: [], + index: embed(valueSchema), + extras: "keep", + }); + const checked = base.narrow((value, ctx: NarrowContext) => { + for (const key in value) { + if (keySchema(key) instanceof type.errors) return ctx.mustBe("a record with valid string keys"); + } + return true; + }); + return decorate(checked); +}; +export const unknown = (): ZodLikeSchema => decorate(schemaFromIR(type.unknown.ir)); +export const any = (): ZodLikeSchema => decorate(schemaFromIR(type.unknown.ir)); +const nullSchema = (): ZodLikeSchema => decorate(type.raw("null") as unknown as Decoratable); +const undefinedSchema = (): ZodLikeSchema => + decorate(type.raw("undefined") as unknown as Decoratable); + +export { nullSchema as null, undefinedSchema as undefined }; + +/** Runtime `z.*` facade, merged with the `z.infer` type namespace below. */ +export const z = { + string, + number, + boolean, + literal, + enum: enumSchema, + union, + array, + object, + record, + unknown, + any, + null: nullSchema, + undefined: undefinedSchema, +}; + +export namespace z { + export type infer = Schema extends { readonly _output: infer Out } ? Out : never; +} diff --git a/packages/omptype/test/infer.test.ts b/packages/omptype/test/infer.test.ts new file mode 100644 index 000000000..247770f51 --- /dev/null +++ b/packages/omptype/test/infer.test.ts @@ -0,0 +1,94 @@ +import type { InferDef, InferDefIn } from "../src/infer"; + +type Eq = (() => t extends a ? 1 : 2) extends () => t extends b ? 1 : 2 ? true : false; + +const _string: Eq, string> = true; +const _number: Eq, number> = true; +const _integer: Eq, number> = true; +const _boolean: Eq, boolean> = true; +const _null: Eq, null> = true; +const _undefined: Eq, undefined> = true; +const _unknown: Eq, unknown> = true; +const _absent: Eq = true; +const _object: Eq, object> = true; +const _bigint: Eq, bigint> = true; +const _url: Eq, string> = true; +const _singleQuoted: Eq, "x"> = true; +const _doubleQuoted: Eq, "x"> = true; +const _digit: Eq, 5> = true; +const _true: Eq, true> = true; +const _false: Eq, false> = true; +const _literalUnion: Eq, "a" | "b"> = true; +const _nullableUnion: Eq, string | null> = true; +const _spacedUnion: Eq, string | null> = true; +const _array: Eq, string[]> = true; +const _unionArray: Eq, (string | number)[]> = true; +const _nestedArray: Eq, string[][]> = true; +const _lowerBound: Eq= 0">, number> = true; +const _stringBounds: Eq, string> = true; +const _numberBounds: Eq, number> = true; +const _numberDefault: Eq, number> = true; +const _stringDefault: Eq, string> = true; +const _booleanDefault: Eq, boolean> = true; +const _garbage: Eq, unknown> = true; +const _dateBound: Eq= d'2020-01-01'">, Date> = true; +const _parseDateOutput: Eq, Date> = true; +const _parseDateInput: Eq, string> = true; +const _parseUrlOutput: Eq, URL> = true; +const _parseUrlInput: Eq, string> = true; +const _partialRecord: Eq>">, Partial>> = true; + +type ObjectDef = { + a: "string"; + "b?": "number"; + "withDefault?": "number = 5"; + "+": "reject"; + nested: { enabled: "boolean"; list: readonly ["string", "[]"] }; +}; +type ObjectOut = { + a: string; + b?: number; + withDefault: number; + nested: { enabled: boolean; list: string[] }; +}; +type ObjectIn = { + a: string; + b?: number; + withDefault?: number; + nested: { enabled: boolean; list: string[] }; +}; +const _objectDefinition: Eq, ObjectOut> = true; +const _mutableTupleArray: Eq, number[]> = true; +const _readonlyTupleArray: Eq, number[]> = true; +const _record: Eq, Record> = true; +const _recordWithProps: Eq< + InferDef<{ name: "string"; "[string]": "unknown" }>, + { name: string } & Record +> = true; + +type Embedded = { infer: { id: number } }; +type DefaultedEmbedded = { infer: string; readonly hasDefault: true }; +const _embedded: Eq, { id: number }> = true; +const _embeddedUnionMember: Eq< + InferDef<{ value: Embedded | { infer: null } }>, + { value: { id: number } | null } +> = true; +const _embeddedDefault: Eq, { name: string }> = true; +const _inputAlias: Eq, ObjectIn> = true; +const _badProperty: Eq, { value: unknown }> = true; +type MorphTuple = readonly ["string.numeric.parse", readonly ["number", "=", 1]]; +const _morphTupleOutput: Eq, [number, number]> = true; +const _morphTupleInput: Eq, [string, number?]> = true; + +// Representative definitions checked against explicit expected output inference. +type FlatTool = { command: "string"; timeout: "number"; "cwd?": "string" }; +type EnumObject = { mode: "'fast' | 'safe'"; "verbose?": "boolean" }; +type NestedTuple = { matrix: readonly [readonly ["number", "[]"], "[]"] }; +type StringRecord = { "[string]": "string" }; +const _arkFlat: Eq, { command: string; timeout: number; cwd?: string }> = true; +const _arkEnum: Eq, { mode: "fast" | "safe"; verbose?: boolean }> = true; +const _arkNested: Eq, { matrix: number[][] }> = true; +const _arkRecord: Eq, Record> = true; +const _arkBounds: Eq, number> = true; +// Definition-level default output is number. +const _arkDefaultOutput: Eq, number> = true; diff --git a/packages/omptype/test/json-schema.test.ts b/packages/omptype/test/json-schema.test.ts new file mode 100644 index 000000000..77d5817e8 --- /dev/null +++ b/packages/omptype/test/json-schema.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "bun:test"; +import type { EmbeddableSchema, IR } from "../src/ir"; +import { IR_BRAND, parseDef } from "../src/ir"; +import { irToJsonSchema } from "../src/json-schema"; + +describe("irToJsonSchema", () => { + it("emits a flat tool schema with optional integer bounds", () => { + const ir = parseDef({ name: "string", "limit?": "1 <= number.integer <= 10" }); + const expected = { + type: "object", + properties: { + name: { type: "string" }, + limit: { type: "integer", minimum: 1, maximum: 10 }, + }, + required: ["name"], + }; + expect(irToJsonSchema(ir)).toEqual(expected); + }); + + it("collapses a described literal union to a typed enum", () => { + const ir: IR = { + k: "union", + members: [ + { k: "lit", v: "read" }, + { k: "lit", v: "write" }, + ], + desc: "Access mode", + }; + const expected = { enum: ["read", "write"], type: "string", description: "Access mode" }; + expect(irToJsonSchema(ir)).toEqual(expected); + }); + + it("emits nested arrays of objects", () => { + const ir = parseDef({ items: [{ id: "string", active: "boolean" }, "[]"] }); + const expected = { + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { id: { type: "string" }, active: { type: "boolean" } }, + required: ["id", "active"], + }, + }, + }, + required: ["items"], + }; + expect(irToJsonSchema(ir)).toEqual(expected); + }); + + it("closes objects whose plus policy is reject", () => { + expect(irToJsonSchema(parseDef({ id: "string", "+": "reject" }))).toEqual({ + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: false, + }); + }); + + it("emits value and factory defaults and excludes them from required", () => { + let factoryCalls = 0; + const ir: IR = { + k: "object", + extras: "keep", + props: [ + { key: "count", opt: false, val: { k: "number" }, def: 5, hasDefault: true }, + { + key: "label", + opt: false, + val: { k: "string" }, + def: () => { + factoryCalls++; + return "generated"; + }, + defFactory: true, + hasDefault: true, + }, + { key: "enabled", opt: false, val: { k: "boolean" } }, + ], + }; + const expected = { + type: "object", + properties: { + count: { type: "number", default: 5 }, + label: { type: "string", default: "generated" }, + enabled: { type: "boolean" }, + }, + required: ["enabled"], + }; + expect(irToJsonSchema(ir)).toEqual(expected); + expect(factoryCalls).toBe(1); + expect(irToJsonSchema(parseDef({ count: "number = 5" }))).toEqual({ + type: "object", + properties: { count: { type: "number", default: 5 } }, + }); + }); + + it("emits an index signature as additionalProperties", () => { + expect(irToJsonSchema(parseDef({ "[string]": "number.integer" }))).toEqual({ + type: "object", + properties: {}, + additionalProperties: { type: "integer" }, + }); + }); + + it("emits URL strings with the uri format", () => { + const expected = { type: "string", format: "uri" }; + expect(irToJsonSchema(parseDef("string.url"))).toEqual(expected); + }); + + it("prunes undefined union members and unwraps the remaining schema", () => { + expect(irToJsonSchema(parseDef("string | undefined"))).toEqual({ type: "string" }); + expect(irToJsonSchema({ k: "union", members: [{ k: "undefined" }, { k: "number" }, { k: "boolean" }] })).toEqual({ + anyOf: [{ type: "number" }, { type: "boolean" }], + }); + }); + + it("falls back through sub nodes and merges the embedded description", () => { + const schema: EmbeddableSchema = { + [IR_BRAND]: true, + ir: { k: "array", el: { k: "number", int: true }, min: 1 }, + hasSteps: true, + hasDefault: false, + description: "Non-empty identifiers", + run: value => value, + }; + expect(irToJsonSchema({ k: "sub", schema })).toEqual({ + type: "array", + items: { type: "integer" }, + minItems: 1, + description: "Non-empty identifiers", + }); + }); + + it("lets a root description option override the IR description", () => { + expect(irToJsonSchema({ k: "string", desc: "inner" }, { description: "root" })).toEqual({ + type: "string", + description: "root", + }); + }); + + it("honors draft targets, explicit dialects, and unsupported-node fallbacks", () => { + expect(irToJsonSchema(parseDef(["string", "number"]), { target: "draft-07" })).toEqual({ + type: "array", + items: [{ type: "string" }, { type: "number" }], + minItems: 2, + maxItems: 2, + additionalItems: false, + $schema: "http://json-schema.org/draft-07/schema#", + }); + expect(irToJsonSchema(parseDef("string"), { dialect: "https://example.test/schema" })).toEqual({ + type: "string", + $schema: "https://example.test/schema", + }); + expect( + irToJsonSchema(parseDef("symbol"), { + fallback: ({ base }) => ({ ...base, type: "string", description: "symbol token" }), + }), + ).toEqual({ type: "string", description: "symbol token" }); + }); +}); diff --git a/packages/omptype/test/type.test.ts b/packages/omptype/test/type.test.ts new file mode 100644 index 000000000..08b95b84b --- /dev/null +++ b/packages/omptype/test/type.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it } from "bun:test"; +import { OmpErrors, Type, type } from "../src"; +import { scope as arkScope } from "../src/ark"; + +/** Call count that guarantees the JIT has kicked in (threshold is 3). */ +const JIT = 5; + +/** Run `fn` against a freshly-built schema on calls 1 (interp) and JIT'd, asserting identical outcomes. */ +function everyStage(build: () => (data: unknown) => unknown, data: unknown): unknown[] { + const schema = build(); + const results: unknown[] = []; + for (let i = 0; i < JIT; i++) results.push(schema(structuredClone(data))); + return results; +} + +function normalized(result: unknown): unknown { + return result instanceof OmpErrors ? { error: result.summary } : result; +} + +describe("validation", () => { + const tool = type({ + path: "string", + "offset?": "number.integer >= 1", + mode: "'read' | 'write' | 'append'", + "tags?": "string[]", + }); + + it("returns the input unchanged for morph-free valid data", () => { + const input = { path: "a.ts", mode: "read" } as const; + expect(tool(input)).toBe(input); + }); + + it("rejects wrong primitive, bad literal, broken bound, and missing key with path-aware errors", () => { + const missing = tool({ mode: "read" }); + expect(missing).toBeInstanceOf(type.errors); + if (missing instanceof OmpErrors) { + expect(missing[0].path).toEqual(["path"]); + expect(missing.summary).toContain("path"); + } + expect(tool({ path: "x", mode: "delete" })).toBeInstanceOf(OmpErrors); + expect(tool({ path: "x", mode: "read", offset: 0 })).toBeInstanceOf(OmpErrors); + expect(tool({ path: "x", mode: "read", offset: 1.5 })).toBeInstanceOf(OmpErrors); + expect(tool({ path: "x", mode: "read", tags: ["a", 1] })).toBeInstanceOf(OmpErrors); + }); + + it("bounds: numbers (exclusive/inclusive) and string length", () => { + const s = type({ t: "0 < number <= 3600", name: "1 <= string <= 8" }); + expect(s({ t: 1, name: "ok" })).toEqual({ t: 1, name: "ok" }); + expect(s({ t: 0, name: "ok" })).toBeInstanceOf(OmpErrors); + expect(s({ t: 3601, name: "ok" })).toBeInstanceOf(OmpErrors); + expect(s({ t: 1, name: "" })).toBeInstanceOf(OmpErrors); + expect(s({ t: 1, name: "toolongname" })).toBeInstanceOf(OmpErrors); + }); + + it("array suffix binds outside a trailing bound: string>0[]", () => { + const s = type({ files: "string>0|string>0[]" }); + expect(s({ files: "a.ts" })).toEqual({ files: "a.ts" }); + expect(s({ files: ["a.ts", "b.ts"] })).toEqual({ files: ["a.ts", "b.ts"] }); + expect(s({ files: [""] })).toBeInstanceOf(OmpErrors); + expect(s({ files: "" })).toBeInstanceOf(OmpErrors); + }); + + it("string.url validates parseability", () => { + const s = type("string.url"); + expect(s("https://omp.sh/x")).toBe("https://omp.sh/x"); + expect(s("not a url")).toBeInstanceOf(OmpErrors); + }); + + it("index signatures validate every value", () => { + const s = type({ env: { "[string]": "string" } }); + expect(s({ env: { A: "1" } })).toEqual({ env: { A: "1" } }); + expect(s({ env: { A: 1 } })).toBeInstanceOf(OmpErrors); + }); + + it("null/undefined union members", () => { + const s = type("string | null"); + expect(s(null)).toBe(null); + expect(s("x")).toBe("x"); + expect(s(5)).toBeInstanceOf(OmpErrors); + }); +}); + +describe("interp/JIT parity", () => { + const cases: { def: Parameters[0]; inputs: unknown[] }[] = [ + { + def: { a: "string", "b?": "number.integer >= 0", c: "'x' | 'y'" }, + inputs: [{ a: "s", c: "x" }, { a: "s", b: -1, c: "x" }, { a: 1, c: "x" }, "nope", { a: "s", c: "z" }], + }, + { + def: { "+": "reject", k: "string", n: "number = 10" }, + inputs: [{ k: "a" }, { k: "a", n: 3 }, { k: "a", extra: true }, { k: "a", n: "3" }], + }, + { + def: { "+": "delete", k: "string", "lvl?": "'low' | 'high'" }, + inputs: [{ k: "a", junk: 1 }, { k: "a", lvl: "low", z: null }, { k: 2 }], + }, + { + def: { items: [{ id: "string", "w?": "number" }, "[]"], env: { "[string]": "string" } }, + inputs: [ + { items: [{ id: "a" }, { id: "b", w: 1 }], env: { X: "1" } }, + { items: [{ id: 1 }], env: {} }, + { items: [], env: { X: 2 } }, + ], + }, + { + def: ["string", ["number", "?"]], + inputs: [["x"], ["x", 1], ["x", 1, 2], [1]], + }, + { + def: [ + ["string", "=", "x"], + ["number", "=", 1], + ], + inputs: [[], ["y"], ["y", 2], [1]], + }, + ]; + + it("every stage of the lazy swap classifies and morphs identically", () => { + for (const c of cases) { + for (const input of c.inputs) { + const results = everyStage(() => type(c.def), input).map(normalized); + for (const r of results.slice(1)) { + expect(r).toEqual(results[0] as never); + } + } + } + }); +}); + +describe("morphs", () => { + it("fills defaults on a fresh object without touching the input", () => { + const s = type({ action: "'get' | 'put'", count: "number.integer = 10" }); + const input = { action: "get" }; + const out = s(input); + expect(out).toEqual({ action: "get", count: 10 }); + expect(out).not.toBe(input); + expect(input).toEqual({ action: "get" }); + }); + + it("deletes extras on a fresh object", () => { + const s = type({ "+": "delete", name: "string" }); + const input = { name: "x", junk: 1 }; + expect(s(input)).toEqual({ name: "x" }); + expect(input).toEqual({ name: "x", junk: 1 }); + }); + + it(".default() factory values are invoked per fill", () => { + const s = type({ list: type("string[]").default(() => []) }); + const a = s({}); + const b = s({}); + expect(a).toEqual({ list: [] }); + if (typeof a === "object" && a !== null && "list" in a && typeof b === "object" && b !== null && "list" in b) { + expect(a.list).not.toBe(b.list); + } + }); +}); + +describe("methods", () => { + it("supports ArkType-compatible Type checks", () => { + const schema = type({ kind: "'first'" }).or({ kind: "'second'", enabled: "boolean = false" }); + expect(schema).toBeInstanceOf(Type); + expect((schema as unknown as { bind?: unknown }).bind).toBeUndefined(); + expect(schema({ kind: "second" })).toEqual({ kind: "second", enabled: false }); + }); + + it(".or() unions with string defs and Types", () => { + const s = type("string").or("null").or(type("number")); + expect(s("x")).toBe("x"); + expect(s(null)).toBe(null); + expect(s(5)).toBe(5); + expect(s(true)).toBeInstanceOf(OmpErrors); + }); + + it(".array() with length bounds", () => { + const s = type({ id: "string" }).array().atLeastLength(1).atMostLength(2); + expect(s([{ id: "a" }])).toEqual([{ id: "a" }]); + expect(s([])).toBeInstanceOf(OmpErrors); + expect(s([{ id: "a" }, { id: "b" }, { id: "c" }])).toBeInstanceOf(OmpErrors); + }); + + it(".pipe() transforms output; chained after validation", () => { + const s = type("string").pipe(v => Number.parseInt(v, 10)); + expect(s("42")).toBe(42); + expect(s(42)).toBeInstanceOf(OmpErrors); + }); + + it(".narrow() with ctx.mustBe records the expectation", () => { + const s = type({ action: "string", "body?": "string" }).narrow( + (p, ctx) => p.action === "delete" || p.body !== undefined || ctx.mustBe("a body unless deleting"), + ); + expect(s({ action: "delete" })).toEqual({ action: "delete" }); + const bad = s({ action: "create" }); + expect(bad).toBeInstanceOf(OmpErrors); + if (bad instanceof OmpErrors) expect(bad.summary).toContain("a body unless deleting"); + }); + + it(".narrow() runs after morphs (sees defaults)", () => { + const s = type({ n: "number = 5" }).narrow(v => v.n === 5); + expect(s({})).toEqual({ n: 5 }); + expect(s({ n: 6 })).toBeInstanceOf(OmpErrors); + }); + + it(".allows() checks without running pipes", () => { + let piped = 0; + const s = type("string").pipe(v => { + piped++; + return v; + }); + expect(s.allows("x")).toBe(true); + expect(s.allows(5)).toBe(false); + expect(piped).toBe(0); + }); + + it(".assert() returns output or throws with the summary", () => { + const s = type({ a: "string" }); + expect(s.assert({ a: "x" })).toEqual({ a: "x" }); + expect(() => s.assert({ a: 5 })).toThrow(/must be/); + }); + + it(".and() merges object schemas", () => { + const s = type({ a: "string" }).and({ "b?": "number" }); + expect(s({ a: "x" })).toEqual({ a: "x" }); + expect(s({ a: "x", b: 2 })).toEqual({ a: "x", b: 2 }); + expect(s({ b: 2 })).toBeInstanceOf(OmpErrors); + }); + + it("type.enumerated builds literal unions from runtime arrays", () => { + const langs = ["py", "js"]; + const s = type.enumerated(...langs); + expect(s("py")).toBe("py"); + expect(s("rs")).toBeInstanceOf(OmpErrors); + }); + + it(".describe() lands in JSON Schema and keeps validation identical", () => { + const s = type("string").describe("a name"); + expect(s.toJsonSchema()).toEqual({ type: "string", description: "a name" }); + expect(s("x")).toBe("x"); + }); +}); + +describe("embedded schemas", () => { + it("stepped sub-schemas morph inside parents and prefix error paths", () => { + const num = type("string").pipe(v => Number.parseInt(v, 10)); + const parent = type({ meta: { port: num } }); + expect(parent({ meta: { port: "8080" } })).toEqual({ meta: { port: 8080 } }); + const bad = parent({ meta: { port: 8080 } }); + expect(bad).toBeInstanceOf(OmpErrors); + if (bad instanceof OmpErrors) { + expect(bad[0].path).toEqual(["meta", "port"]); + } + }); + + it("parity holds for stepped sub-schemas across the JIT swap", () => { + const num = type("string").pipe(v => Number.parseInt(v, 10)); + const results = everyStage(() => type({ port: num }), { port: "1" }).map(normalized); + for (const r of results) expect(r).toEqual({ port: 1 }); + }); + + it("union failures descend into the kind-matched member across the JIT swap", () => { + // string | array-of-objects: an array input must produce the array + // member's element-level error, not the coarse union expectation + const item = type({ kind: "'a' | 'b'", id: "string" }); + const results = everyStage(() => type("string").or(item.array()), [{ kind: "a" }]).map(normalized); + for (const r of results) { + expect(r).toEqual(results[0] as never); + const err = (r as { error: string }).error; + expect(err).toContain("id"); + expect(err).toContain("missing"); + } + }); + + it("union failures discriminate object members by literal props", () => { + const move = type({ op: "'move'", to: "string" }); + const wait = type({ op: "'wait'", ms: "number" }); + const s = type(move.or(wait)); + for (let i = 0; i < JIT; i++) { + const out = s({ op: "wait", ms: "soon" }); + expect(out).toBeInstanceOf(OmpErrors); + if (out instanceof OmpErrors) { + expect(out[0].path).toEqual(["ms"]); + expect(out.summary).toContain("a number"); + } + } + }); +}); + +describe("error contract", () => { + it("entries support stable indexing, iteration, map(), and filter()", () => { + const s = type({ server: { port: "number.integer" } }); + const out = s({ server: { port: "80" } }); + expect(out).toBeInstanceOf(OmpErrors); + if (out instanceof OmpErrors) { + const mapped = out.map(e => ({ + instancePath: e.path.length === 0 ? "root" : e.path.join("."), + message: e.problem, + })); + const [entry] = out; + expect(out.length).toBe(1); + expect(entry).toBe(out[0]); + expect(out[0]).toBe(out[0]); + expect(out.filter(() => true)).toEqual([out[0]]); + expect(mapped).toEqual([ + { instancePath: "server.port", message: expect.stringContaining("must be an integer") }, + ]); + } + }); + + it("summary is line-per-error human text", () => { + const s = type({ a: "string" }); + const out = s({}); + if (out instanceof OmpErrors) { + expect(out.summary).toContain("a "); + expect(out.summary).toContain("missing"); + } + }); +}); + +describe("advanced ArkType compatibility", () => { + it("re-exports recursive scopes through the Ark compatibility facade", () => { + const schemas = arkScope({ Node: { value: "number", "next?": "Node" } }, { jitless: true }).export(); + expect(schemas.Node({ value: 1, next: { value: 2 } })).toEqual({ + value: 1, + next: { value: 2 }, + }); + const invalid = schemas.Node({ value: 1, next: { value: "two" } }); + expect(invalid).toBeInstanceOf(OmpErrors); + if (invalid instanceof OmpErrors) expect(invalid[0].path).toEqual(["next", "value"]); + }); + + it("resolves recursive scopes, modules, utility generics, and runtime generics", () => { + const schemas = type + .scope({ + User: { name: "string", "manager?": "User" }, + Users: "User[]", + Directory: "Record", + Public: "Pick", + Maybe: "Partial", + }) + .export(); + expect(schemas.User({ name: "Ada", manager: { name: "Grace" } })).toEqual({ + name: "Ada", + manager: { name: "Grace" }, + }); + expect(schemas.Users([{ name: "Ada" }])).toEqual([{ name: "Ada" }]); + expect(schemas.Directory({ lead: { name: "Ada" } })).toEqual({ lead: { name: "Ada" } }); + expect(schemas.Public({ name: "Ada" })).toEqual({ name: "Ada" }); + expect(schemas.Maybe({})).toEqual({}); + const bad = schemas.User({ name: "Ada", manager: { name: 1 } }); + expect(bad).toBeInstanceOf(OmpErrors); + if (bad instanceof OmpErrors) expect(bad[0].path).toEqual(["manager", "name"]); + + const module = type.module({ Name: "string", User: { name: "Name" } }); + expect(module.User({ name: "Ada" })).toEqual({ name: "Ada" }); + const box = type.generic("", { value: "value" }); + expect(box("number")({ value: 2 })).toEqual({ value: 2 }); + expect(box("number")({ value: "2" })).toBeInstanceOf(OmpErrors); + }); + + it("supports fixed, optional, defaulted, and variadic tuple elements", () => { + const fixed = type(["string", "number"]); + expect(fixed(["x", 1])).toEqual(["x", 1]); + expect(fixed(["x"])).toBeInstanceOf(OmpErrors); + + const optional = type(["string", ["number", "?"]]); + expect(optional(["x"])).toEqual(["x"]); + expect(optional(["x", 1])).toEqual(["x", 1]); + expect(optional(["x", 1, 2])).toBeInstanceOf(OmpErrors); + + const variadic = type(["string", "...", ["boolean", "[]"], "number"]); + expect(variadic(["x", 1])).toEqual(["x", 1]); + expect(variadic(["x", true, false, 1])).toEqual(["x", true, false, 1]); + expect(variadic(["x", true])).toBeInstanceOf(OmpErrors); + + const defaulted = type([ + ["string", "=", "x"], + ["number", "=", 1], + ]); + expect(defaulted([])).toEqual(["x", 1]); + expect(defaulted(["y"])).toEqual(["y", 1]); + }); + + it("validates Date literals and bounds before and after JIT compilation", () => { + const exact = type("d'2024-01-02T00:00:00.000Z'"); + const bounded = type("Date >= d'2024-01-01T00:00:00.000Z'"); + for (let index = 0; index < JIT; index++) { + expect(exact(new Date("2024-01-02T00:00:00.000Z"))).toEqual(new Date("2024-01-02T00:00:00.000Z")); + expect(exact(new Date("2024-01-03T00:00:00.000Z"))).toBeInstanceOf(OmpErrors); + expect(bounded(new Date("2024-01-01T00:00:00.000Z"))).toEqual(new Date("2024-01-01T00:00:00.000Z")); + expect(bounded(new Date("2023-12-31T23:59:59.999Z"))).toBeInstanceOf(OmpErrors); + } + }); + + it("supports disjointness-aware intersections and semantic comparison", () => { + const bounded = type("number > 0").and("number < 3"); + expect(bounded(2)).toBe(2); + expect(bounded(0)).toBeInstanceOf(OmpErrors); + expect(type("'a' | 'b'").and("'b' | 'c'")("b")).toBe("b"); + const stringSchema = type.raw("string"); + const and = Reflect.get(stringSchema, "and"); + if (typeof and !== "function") throw new Error("schema is missing and()"); + expect(() => Reflect.apply(and, stringSchema, ["number"])).toThrow("unsatisfiable"); + expect(type("0 < number").equals("number > 0")).toBe(true); + expect(type("number.integer > 0").extends("number")).toBe(true); + expect(type("'a' | 'b'").overlaps("'b' | 'c'")).toBe(true); + expect(type("string").overlaps("number")).toBe(false); + }); + + it("maps and selects structural nodes and distributes unions", () => { + const object = type({ name: "string", "age?": "number" }); + expect(object.props.map(prop => [prop.kind, prop.key])).toEqual([ + ["required", "name"], + ["optional", "age"], + ]); + const renamed = object.map(prop => (prop.key === "name" ? { ...prop, key: "label" } : [])); + expect(renamed({ label: "Ada" })).toEqual({ label: "Ada" }); + expect( + type("'red' | 'blue'") + .select("unit") + .map(node => node.unit), + ).toEqual(["red", "blue"]); + const distributed = type("'a' | 'b'").distribute(branch => branch.array()); + expect(distributed(["a"])).toEqual(["a"]); + expect(distributed(["c"])).toBeInstanceOf(OmpErrors); + }); + + it("exposes nested keyword modules and configurable error codes", () => { + expect(type.string.ip.v4("127.0.0.1")).toBe("127.0.0.1"); + expect(type.string.uuid.v4("550e8400-e29b-41d4-a716-446655440000")).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(type.string.trim.preformatted("trimmed")).toBe("trimmed"); + expect(type.string.date.iso.parse("2024-01-02")).toBeInstanceOf(Date); + expect(type.parse.url("https://omp.sh")).toBeInstanceOf(URL); + + const configured = type({ user: { age: "number >= 18" } }).configure({ + expected: context => `expected:${context.code}`, + message: context => `${context.path.join("/")}: ${context.problem}`, + }); + for (let index = 0; index < JIT; index++) { + const out = configured({ user: { age: 10 } }); + expect(out).toBeInstanceOf(OmpErrors); + if (out instanceof OmpErrors) { + expect(out[0].code).toBe("min"); + expect(out.summary).toContain("user/age: must be expected:min"); + expect(Object.keys(out.byPath)).toEqual(["user.age"]); + } + } + }); +}); diff --git a/packages/omptype/test/typebox.test.ts b/packages/omptype/test/typebox.test.ts new file mode 100644 index 000000000..f41e7911f --- /dev/null +++ b/packages/omptype/test/typebox.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "../src/type"; +import { type Static, Type } from "../src/typebox"; + +type Eq = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Assert = T; + +function valid(schema: (value: unknown) => unknown, value: unknown): boolean { + return !(schema(value) instanceof type.errors); +} + +describe("TypeBox adapter", () => { + test("primitive, literal, union, enum, nullable and never builders validate", () => { + expect(valid(Type.String(), "x")).toBe(true); + expect(valid(Type.String(), 1)).toBe(false); + expect(valid(Type.Number(), 1.5)).toBe(true); + expect(valid(Type.Number(), "1")).toBe(false); + expect(valid(Type.Integer(), 2)).toBe(true); + expect(valid(Type.Integer(), 2.5)).toBe(false); + expect(valid(Type.Boolean(), true)).toBe(true); + expect(valid(Type.Boolean(), 1)).toBe(false); + expect(valid(Type.Null(), null)).toBe(true); + expect(valid(Type.Null(), undefined)).toBe(false); + expect(valid(Type.Literal("ok"), "ok")).toBe(true); + expect(valid(Type.Literal("ok"), "no")).toBe(false); + expect(valid(Type.Union([Type.Literal("a"), Type.Number()]), 3)).toBe(true); + expect(valid(Type.Union([Type.Literal("a"), Type.Number()]), false)).toBe(false); + expect(valid(Type.Enum(["red", "blue"] as const), "red")).toBe(true); + expect(valid(Type.Enum(["red", "blue"] as const), "green")).toBe(false); + expect(valid(Type.Nullable(Type.String()), null)).toBe(true); + expect(valid(Type.Nullable(Type.String()), false)).toBe(false); + expect(valid(Type.Never(), "anything")).toBe(false); + expect(valid(Type.Any(), { anything: true })).toBe(true); + expect(valid(Type.Unknown(), Symbol("anything"))).toBe(true); + expect(valid(Type.Unsafe({ type: "string" }), 42)).toBe(true); + }); + + test("string and number constraints validate", () => { + const string = Type.String({ minLength: 2, maxLength: 4, pattern: "^[a-z]+$", format: "email" }); + expect(valid(string, "a@b.co")).toBe(false); + const pattern = Type.String({ minLength: 2, maxLength: 4, pattern: "^[a-z]+$" }); + expect(valid(pattern, "ab")).toBe(true); + expect(valid(pattern, "a")).toBe(false); + expect(valid(pattern, "ABCDE")).toBe(false); + expect(valid(pattern, "a1")).toBe(false); + expect(valid(Type.String({ format: "email" }), "a@b.co")).toBe(true); + expect(valid(Type.String({ format: "email" }), "nope")).toBe(false); + expect(Type.String({ format: "url" }).toJsonSchema()).toEqual({ type: "string", format: "uri" }); + + const number = Type.Number({ minimum: 1, maximum: 10, multipleOf: 2 }); + expect(valid(number, 4)).toBe(true); + expect(valid(number, 0)).toBe(false); + expect(valid(number, 3)).toBe(false); + const exclusive = Type.Number({ exclusiveMinimum: 1, exclusiveMaximum: 3 }); + expect(valid(exclusive, 2)).toBe(true); + expect(valid(exclusive, 1)).toBe(false); + expect(valid(exclusive, 3)).toBe(false); + expect(exclusive.toJsonSchema()).toEqual({ type: "number", exclusiveMinimum: 1, exclusiveMaximum: 3 }); + }); + + test("arrays, tuples, objects, records and intersections validate", () => { + const array = Type.Array(Type.Number(), { minItems: 1, maxItems: 3, uniqueItems: true }); + expect(valid(array, [1, 2])).toBe(true); + expect(valid(array, [])).toBe(false); + expect(valid(array, [1, 1])).toBe(false); + expect(valid(array, [1, "2"])).toBe(false); + + const tuple = Type.Tuple([Type.String(), Type.Number()] as const); + expect(valid(tuple, ["x", 1])).toBe(true); + expect(valid(tuple, ["x"])).toBe(false); + expect(valid(tuple, ["x", "1"])).toBe(false); + + const object = Type.Object( + { name: Type.String(), age: Type.Optional(Type.Integer()) }, + { additionalProperties: false }, + ); + expect(valid(object, { name: "Ada" })).toBe(true); + expect(valid(object, { name: "Ada", age: 37 })).toBe(true); + expect(valid(object, { age: 37 })).toBe(false); + expect(valid(object, { name: "Ada", extra: true })).toBe(false); + const indexed = Type.Object({ known: Type.Number() }, { additionalProperties: Type.Number() }); + expect(valid(indexed, { known: 1, extra: 2 })).toBe(true); + expect(valid(indexed, { known: 1, extra: "bad" })).toBe(false); + + const record = Type.Record(Type.String({ pattern: "^[a-z]+$" }), Type.Number()); + expect(valid(record, { a: 1, b: 2 })).toBe(true); + expect(valid(record, { A: 1 })).toBe(false); + expect(valid(record, { a: "1" })).toBe(false); + + const intersection = Type.Intersect([Type.Object({ a: Type.String() }), Type.Object({ b: Type.Number() })]); + expect(valid(intersection, { a: "x", b: 1 })).toBe(true); + expect(valid(intersection, { a: "x" })).toBe(false); + }); + + test("object transforms preserve property validation", () => { + const base = Type.Object({ a: Type.String(), b: Type.Number() }, { additionalProperties: false }); + expect(valid(Type.Partial(base), {})).toBe(true); + expect(valid(Type.Partial(base), { a: 1 })).toBe(false); + expect(valid(Type.Pick(base, ["a"] as const), { a: "x" })).toBe(true); + expect(valid(Type.Pick(base, ["a"] as const), { b: 1 })).toBe(false); + expect(valid(Type.Omit(base, ["b"] as const), { a: "x" })).toBe(true); + const composite = Type.Composite([Type.Object({ a: Type.String() }), Type.Object({ b: Type.Number() })]); + expect(valid(composite, { a: "x", b: 1 })).toBe(true); + expect(valid(composite, { a: "x" })).toBe(false); + expect(Type.Readonly(base)).toBe(base); + }); + + test("metadata and object JSON Schema are emitted", () => { + const schema = Type.Object( + { + name: Type.String({ description: "Display name" }), + nick: Type.Optional(Type.String()), + score: Type.Number({ default: 0 }), + }, + { description: "A person", additionalProperties: false }, + ); + expect(schema({ name: "Ada" })).toEqual({ name: "Ada", score: 0 }); + expect(schema.toJsonSchema()).toEqual({ + type: "object", + properties: { + name: { type: "string", description: "Display name" }, + nick: { type: "string" }, + score: { type: "number", default: 0 }, + }, + required: ["name"], + additionalProperties: false, + description: "A person", + }); + }); + + test("legacy validation helpers are non-enumerable and return compatibility results", () => { + const schema = Type.Object({ name: Type.String(), score: Type.Number({ default: 1 }) }); + expect(schema.safeParse({ name: "Ada" })).toEqual({ + success: true, + data: { name: "Ada", score: 1 }, + }); + const failure = schema.safeParse({ name: 42 }); + expect(failure.success).toBe(false); + if (!failure.success) expect(failure.error.message).toContain("name"); + + expect(schema.__validator({ name: "Ada" })).toEqual({ name: "Ada", score: 1 }); + const validationFailure = schema.__validator({}); + expect(validationFailure).toEqual({ message: expect.any(String) }); + expect(Object.keys(schema)).not.toContain("safeParse"); + expect(Object.keys(schema)).not.toContain("__validator"); + expect(Object.getOwnPropertyDescriptor(schema, "safeParse")?.enumerable).toBe(false); + expect(Object.getOwnPropertyDescriptor(schema, "__validator")?.enumerable).toBe(false); + }); + + test("Static infers builder outputs", () => { + const literal = Type.Literal("yes"); + const tuple = Type.Tuple([Type.String(), Type.Number()] as const); + const object = Type.Object({ id: Type.Number(), label: Type.Optional(Type.String()) }); + type _Literal = Assert, "yes">>; + type _Tuple = Assert, [string, number]>>; + type ExpectedObject = { id: number } & { label?: string }; + type _Object = Assert, ExpectedObject>>; + expect(valid(object, { id: 1 })).toBe(true); + }); +}); diff --git a/packages/omptype/test/zod.test.ts b/packages/omptype/test/zod.test.ts new file mode 100644 index 000000000..0768f13b1 --- /dev/null +++ b/packages/omptype/test/zod.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import { OmpTypeError } from "../src/errors"; +import { Type, type } from "../src/type"; +import * as zod from "../src/zod"; +import { z } from "../src/zod"; + +type Eq = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Assert = T; + +const typedUser = z.object({ + name: z.string(), + nickname: z.string().optional(), + score: z.number().optional().default(10), + tags: z.array(z.string()), +}); +type _UserInference = Assert< + Eq, { name: string; nickname?: string | undefined; score: number; tags: string[] }> +>; +const optionalText = z.string().optional(); +type _OptionalInference = Assert, string | undefined>>; +const transformed = z.string().transform(value => value.length); +type _TransformInference = Assert, number>>; +const nullableOptional = z.number().nullable().optional(); +type _NullableOptionalInference = Assert, number | null | undefined>>; + +describe("zod-like parsing", () => { + it("exposes callable omptype schemas with JSON Schema metadata", () => { + const text = z.string().min(1); + const schema = z.object({ + name: z.string().default("Ada"), + website: z.string().url().describe("Public profile").optional(), + }); + for (const candidate of [z.string(), text, schema]) { + expect(typeof candidate).toBe("function"); + expect(candidate).toBeInstanceOf(Type); + expect(typeof candidate.toJsonSchema).toBe("function"); + expect(typeof candidate.assert).toBe("function"); + } + expect(schema({ website: "https://omp.sh" })).toEqual({ + name: "Ada", + website: "https://omp.sh", + }); + expect(schema({ website: 42 })).toBeInstanceOf(type.errors); + expect(schema.toJsonSchema()).toEqual({ + type: "object", + properties: { + name: { type: "string", default: "Ada" }, + website: { type: "string", format: "uri", description: "Public profile" }, + }, + }); + expect(zod.object({ value: zod.string() }).parse({ value: "top-level export" })).toEqual({ + value: "top-level export", + }); + }); + + it("parses valid values and reports nested safeParse issues", () => { + const schema = z.object({ profile: z.object({ age: z.number().int().positive() }) }); + expect(schema.parse({ profile: { age: 42 } })).toEqual({ profile: { age: 42 } }); + + const result = schema.safeParse({ profile: { age: -1 } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain("profile.age"); + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0]?.path).toEqual(["profile", "age"]); + expect(result.error.issues[0]?.message).toContain("must be"); + } + expect(() => schema.parse({ profile: { age: "old" } })).toThrow("profile.age"); + }); + + it("supports optional properties, defaults, and object key modes", () => { + const base = z.object({ + name: z.string(), + nickname: z.string().optional(), + score: z.number().default(7), + }); + expect(base.parse({ name: "Ada", ignored: true })).toEqual({ name: "Ada", score: 7 }); + expect(base.parse({ name: "Ada", nickname: undefined })).toEqual({ + name: "Ada", + nickname: undefined, + score: 7, + }); + expect(z.string().default("fallback").parse(undefined)).toBe("fallback"); + expect(base.strict().safeParse({ name: "Ada", extra: 1 }).success).toBe(false); + expect(base.passthrough().parse({ name: "Ada", extra: 1 })).toEqual({ name: "Ada", score: 7, extra: 1 }); + expect(base.strip().parse({ name: "Ada", extra: 1 })).toEqual({ name: "Ada", score: 7 }); + expect(z.object({ left: z.string(), right: z.number() }).partial().parse({ left: "x" })).toEqual({ left: "x" }); + }); + + it("refines, transforms, and catches every invalid inner result", () => { + const evenLength = z + .string() + .refine(value => value.length % 2 === 0, "an even-length string") + .transform(value => value.length); + expect(evenLength.parse("four")).toBe(4); + const invalid = evenLength.safeParse("odd"); + expect(invalid.success).toBe(false); + if (!invalid.success) expect(invalid.error.issues[0]?.message).toContain("an even-length string"); + + const resilient = z + .number() + .positive() + .transform(value => value * 2) + .catch(12); + expect(resilient.parse(3)).toBe(6); + expect(resilient.parse(-3)).toBe(12); + expect(resilient.parse("bad")).toBe(12); + expect(resilient.safeParse("bad")).toEqual({ success: true, data: 12 }); + const catchesThrownTransform = z + .string() + .transform((): string => { + throw new Error("boom"); + }) + .catch("recovered"); + expect(() => catchesThrownTransform.parse("x")).not.toThrow(); + expect(catchesThrownTransform.parse("x")).toBe("recovered"); + }); + + it("supports literals, enums, and unions", () => { + const status = z.enum(["queued", "done"] as const); + expect(status.parse("queued")).toBe("queued"); + expect(status.safeParse("failed").success).toBe(false); + + const choice = z.union([z.literal("auto"), z.number()] as const); + expect(choice.parse("auto")).toBe("auto"); + expect(choice.parse(3)).toBe(3); + expect(choice.safeParse(false).success).toBe(false); + expect(z.literal(null).parse(null)).toBeNull(); + }); + + it("dispatches min and max by string, number, and array kind", () => { + const text = z.string().min(2).max(4); + expect(text.parse("good")).toBe("good"); + expect(text.safeParse("x").success).toBe(false); + expect(text.safeParse("lengthy").success).toBe(false); + + const amount = z.number().min(2).max(4); + expect(amount.parse(3)).toBe(3); + expect(amount.safeParse(1).success).toBe(false); + expect(amount.safeParse(5).success).toBe(false); + expect(z.number().min(3).min(2).safeParse(2.5).success).toBe(false); + expect(z.number().max(3).max(4).safeParse(3.5).success).toBe(false); + expect(z.number().min(3).positive().safeParse(2).success).toBe(false); + + const list = z.array(z.boolean()).min(1).max(2); + expect(list.parse([true, false])).toEqual([true, false]); + expect(list.safeParse([]).success).toBe(false); + expect(list.safeParse([true, false, true]).success).toBe(false); + expect(() => z.boolean().min(1)).toThrow(OmpTypeError); + }); + + it("supports string and number refinements plus nullable and optional values", () => { + expect(z.string().regex(/^omp$/).url().safeParse("omp").success).toBe(false); + expect(z.string().url().parse("https://omp.sh")).toBe("https://omp.sh"); + expect(z.number().int().nonnegative().parse(0)).toBe(0); + expect(z.number().int().safeParse(1.5).success).toBe(false); + expect(z.number().positive().safeParse(0).success).toBe(false); + expect(nullableOptional.parse(null)).toBeNull(); + expect(nullableOptional.parse(undefined)).toBeUndefined(); + }); + + it("validates records with string key and value schemas", () => { + const env = z.record(z.string().regex(/^[A-Z]+$/), z.string()); + expect(env.parse({ HOME: "/tmp" })).toEqual({ HOME: "/tmp" }); + expect(env.safeParse({ home: "/tmp" }).success).toBe(false); + expect(env.safeParse({ HOME: 1 }).success).toBe(false); + expect(() => z.record(z.number() as never, z.string())).toThrow(OmpTypeError); + const flags = z.record(z.enum(["A", "B"] as const), z.boolean()); + expect(flags.parse({ A: true, B: false })).toEqual({ A: true, B: false }); + expect(flags.safeParse({ C: true }).success).toBe(false); + }); +}); diff --git a/packages/omptype/tsconfig.json b/packages/omptype/tsconfig.json new file mode 100644 index 000000000..245da9824 --- /dev/null +++ b/packages/omptype/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.workspace.json", + "include": ["src", "test", "bench"] +} diff --git a/packages/omptype/tsconfig.publish.js.json b/packages/omptype/tsconfig.publish.js.json new file mode 100644 index 000000000..535c81f3a --- /dev/null +++ b/packages/omptype/tsconfig.publish.js.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "inlineSources": false, + "rootDir": "src", + "outDir": "dist/js", + "noCheck": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "test", "bench"] +} diff --git a/packages/omptype/tsconfig.publish.json b/packages/omptype/tsconfig.publish.json new file mode 100644 index 000000000..197500e36 --- /dev/null +++ b/packages/omptype/tsconfig.publish.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "inlineSources": false, + "rootDir": "src", + "outDir": "dist/types", + "noCheck": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/tui/bench/_jskey.ts b/packages/tui/bench/_jskey.ts index 87fb183ef..bfdf59707 100644 --- a/packages/tui/bench/_jskey.ts +++ b/packages/tui/bench/_jskey.ts @@ -28,14 +28,14 @@ let kittyProtocolActive = false; * Set the global Kitty keyboard protocol state. * Called by ProcessTerminal after detecting protocol support. */ -function setKittyProtocolActive(active: boolean): void { +export function setKittyProtocolActive(active: boolean): void { kittyProtocolActive = active; } /** * Query whether Kitty keyboard protocol is currently active. */ -function isKittyProtocolActive(): boolean { +export function isKittyProtocolActive(): boolean { return kittyProtocolActive; } @@ -169,7 +169,7 @@ export type KeyId = * - Key.ctrl("c"), Key.alt("x") for single modifier * - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers */ -const Key = { +export const Key = { // Special keys escape: "escape" as const, esc: "esc" as const, @@ -487,14 +487,11 @@ interface ParsedKittySequence { eventType: KeyEventType; } -// Store the last parsed event type for isKeyRelease() to query -let lastEventType: KeyEventType = "press"; - /** * Check if the last parsed key event was a key release. * Only meaningful when Kitty keyboard protocol with flag 2 is active. */ -function isKeyRelease(data: string): boolean { +export function isKeyRelease(data: string): boolean { // Don't treat bracketed paste content as key release, even if it contains // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5"). // Terminal.ts re-wraps paste content with bracketed paste markers before @@ -524,7 +521,7 @@ function isKeyRelease(data: string): boolean { * Check if the last parsed key event was a key repeat. * Only meaningful when Kitty keyboard protocol with flag 2 is active. */ -function isKeyRepeat(data: string): boolean { +export function isKeyRepeat(data: string): boolean { // Don't treat bracketed paste content as key repeat, even if it contains // patterns like ":2F". See isKeyRelease() for details. if (data.includes("\x1b[200~")) { @@ -572,7 +569,6 @@ function parseKittySequence(data: string): ParsedKittySequence | null { const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined; const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1; const eventType = parseEventType(csiUMatch[5]); - lastEventType = eventType; return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }; } @@ -582,7 +578,6 @@ function parseKittySequence(data: string): ParsedKittySequence | null { const modValue = parseInt(arrowMatch[1]!, 10); const eventType = parseEventType(arrowMatch[2]); const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; - lastEventType = eventType; return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; } @@ -602,7 +597,6 @@ function parseKittySequence(data: string): ParsedKittySequence | null { }; const codepoint = funcCodes[keyNum]; if (codepoint !== undefined) { - lastEventType = eventType; return { codepoint, modifier: modValue - 1, eventType }; } } @@ -613,7 +607,6 @@ function parseKittySequence(data: string): ParsedKittySequence | null { const modValue = parseInt(homeEndMatch[1]!, 10); const eventType = parseEventType(homeEndMatch[2]); const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; - lastEventType = eventType; return { codepoint, modifier: modValue - 1, eventType }; } @@ -702,7 +695,7 @@ function parseKeyId(keyId: string): ParsedKeyId | null { * @param data - Raw input data from terminal * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) */ -function matchesKey(data: string, keyId: KeyId): boolean { +export function matchesKey(data: string, keyId: KeyId): boolean { const parsed = parseKeyId(keyId); if (!parsed) return false; @@ -1048,7 +1041,7 @@ function matchesKey(data: string, keyId: KeyId): boolean { * @param data - Raw input data from terminal * @returns Key identifier string (e.g., "ctrl+c") or undefined */ -function parseKey(data: string): string | undefined { +export function parseKey(data: string): string | undefined { const kitty = parseKittySequence(data); if (kitty) { const { codepoint, baseLayoutKey, modifier } = kitty; diff --git a/packages/tui/bench/fuzzy.bench.ts b/packages/tui/bench/fuzzy.bench.ts index 30acc5e2d..8788ea72b 100644 --- a/packages/tui/bench/fuzzy.bench.ts +++ b/packages/tui/bench/fuzzy.bench.ts @@ -25,22 +25,71 @@ import { fuzzyFilter, fuzzyRank, resetFuzzyIndexCache } from "../src/fuzzy"; // every run so the golden checksums stay valid. const BASES = [ - "openai/gpt-4o", "openai/gpt-4o-mini", "openai/gpt-4.1", "openai/gpt-4.1-mini", - "openai/gpt-4-turbo", "openai/gpt-5", "openai/gpt-5-mini", "openai/o3", "openai/o3-mini", "openai/o4-mini", - "anthropic/claude-3.5-sonnet", "anthropic/claude-3.5-haiku", "anthropic/claude-3-7-sonnet", - "anthropic/claude-3-opus", "anthropic/claude-4-sonnet", "anthropic/claude-4-opus", "anthropic/claude-4.5-sonnet", - "google/gemini-2.0-flash", "google/gemini-2.5-pro", "google/gemini-2.5-flash", "google/gemini-1.5-pro", - "meta/llama-3.3-70b", "meta/llama-3.1-405b", "meta/llama-4-scout", "meta/llama-4-maverick", - "mistral/mistral-large", "mistral/codestral", "deepseek/deepseek-v3", "deepseek/deepseek-r1", - "xai/grok-3", "xai/grok-4", "qwen/qwen3-coder", "qwen/qwen3-235b", "qwen/qwen-max", "amazon/nova-pro", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "openai/gpt-4.1", + "openai/gpt-4.1-mini", + "openai/gpt-4-turbo", + "openai/gpt-5", + "openai/gpt-5-mini", + "openai/o3", + "openai/o3-mini", + "openai/o4-mini", + "anthropic/claude-3.5-sonnet", + "anthropic/claude-3.5-haiku", + "anthropic/claude-3-7-sonnet", + "anthropic/claude-3-opus", + "anthropic/claude-4-sonnet", + "anthropic/claude-4-opus", + "anthropic/claude-4.5-sonnet", + "google/gemini-2.0-flash", + "google/gemini-2.5-pro", + "google/gemini-2.5-flash", + "google/gemini-1.5-pro", + "meta/llama-3.3-70b", + "meta/llama-3.1-405b", + "meta/llama-4-scout", + "meta/llama-4-maverick", + "mistral/mistral-large", + "mistral/codestral", + "deepseek/deepseek-v3", + "deepseek/deepseek-r1", + "xai/grok-3", + "xai/grok-4", + "qwen/qwen3-coder", + "qwen/qwen3-235b", + "qwen/qwen-max", + "amazon/nova-pro", +]; +const VARIANTS = [ + "", + "-2024-06-01", + "-2025-03-01", + "-latest", + "-preview", + "-0513", + "-0806", + "-fp8", + "-q4-k-m", + "-32k", + "-128k", ]; -const VARIANTS = ["", "-2024-06-01", "-2025-03-01", "-latest", "-preview", "-0513", "-0806", "-fp8", "-q4-k-m", "-32k", "-128k"]; const FILES = [ - "src/components/markdown.ts", "src/tools/read.ts", "src/tools/grep.ts", "src/utils/git.ts", - "src/modes/theme/theme.ts", "src/system-prompt.ts", "src/workspace-tree.ts", - "packages/tui/src/fuzzy.ts", "packages/tui/src/autocomplete.ts", "packages/tui/src/utils.ts", - "crates/pi-natives/src/grep.rs", "crates/pi-ast/src/summary.rs", "crates/pi-shell/src/shell.rs", - "packages/coding-agent/src/tools/write.ts", "packages/coding-agent/src/tools/bash.ts", + "src/components/markdown.ts", + "src/tools/read.ts", + "src/tools/grep.ts", + "src/utils/git.ts", + "src/modes/theme/theme.ts", + "src/system-prompt.ts", + "src/workspace-tree.ts", + "packages/tui/src/fuzzy.ts", + "packages/tui/src/autocomplete.ts", + "packages/tui/src/utils.ts", + "crates/pi-natives/src/grep.rs", + "crates/pi-ast/src/summary.rs", + "crates/pi-shell/src/shell.rs", + "packages/coding-agent/src/tools/write.ts", + "packages/coding-agent/src/tools/bash.ts", ]; function buildCorpus(): string[] { @@ -64,13 +113,13 @@ function fnv1a(str: string): string { } const GOLDENS: Record = { - "gpt4": "907188d2", - "claude": "a424a682", - "rs": "d6922083", - "src": "f82e2f7e", + gpt4: "907188d2", + claude: "a424a682", + rs: "d6922083", + src: "f82e2f7e", "deepseek r1": "6f6aad2d", - "son": "79c9d65f", - "o3": "edf71867", + son: "79c9d65f", + o3: "edf71867", "4o": "84ede5df", "0513": "563822f3", }; @@ -78,7 +127,9 @@ const GOLDENS: Record = { function assertGolden(corpus: string[]): void { let failed = false; for (const [query, golden] of Object.entries(GOLDENS)) { - const out = fuzzyRank(corpus, query, t => t).map(r => r.item).join("\n"); + const out = fuzzyRank(corpus, query, t => t) + .map(r => r.item) + .join("\n"); const hash = fnv1a(out); if (hash !== golden) { console.error(`GOLDEN MISMATCH for "${query}": expected ${golden}, got ${hash}`); diff --git a/packages/tui/bench/markdown-stream.ts b/packages/tui/bench/markdown-stream.ts index 69565e1f6..1aeeb7bd6 100644 --- a/packages/tui/bench/markdown-stream.ts +++ b/packages/tui/bench/markdown-stream.ts @@ -49,7 +49,7 @@ function identifierProse(paragraphs: number): string { function fences(count: number): string { const parts: string[] = []; for (let i = 0; i < count; i++) { - parts.push("```ts\nconst x_" + i + " = await fetch(\"https://api.example.com/v1/usage\");\n```\n"); + parts.push(`\`\`\`ts\nconst x_${i} = await fetch("https://api.example.com/v1/usage");\n\`\`\`\n`); } return `${parts.join("\n")}\n`; } diff --git a/packages/tui/bench/parse-key.ts b/packages/tui/bench/parse-key.ts index 5e7659fe6..47dbd2a43 100644 --- a/packages/tui/bench/parse-key.ts +++ b/packages/tui/bench/parse-key.ts @@ -1,7 +1,7 @@ import { parseKey as nativeParseKey } from "@oh-my-pi/pi-natives"; import * as native from "../src/keys"; -import * as js from "./_jskey"; import { makeBench } from "./_harness"; +import * as js from "./_jskey"; const ITERATIONS = 2000; diff --git a/packages/tui/bench/sanitize.ts b/packages/tui/bench/sanitize.ts index 721d0b052..59f14bdac 100644 --- a/packages/tui/bench/sanitize.ts +++ b/packages/tui/bench/sanitize.ts @@ -113,134 +113,135 @@ function skipRunSanitizeText(text: string): string { return out; } - const REMOVAL_START_RE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?= 0xd800 && firstCode <= 0xdbff && first + 1 < text.length) { - const lo = text.charCodeAt(first + 1); - if (lo >= 0xdc00 && lo <= 0xdfff && text.isWellFormed()) { - CONTROL_RE_G.lastIndex = first + 2; - m = CONTROL_RE_G.exec(text); - if (m === null) return text; - return sanitizeWellFormedControlFrom(text, m); - } - } - return sanitizeNeedsFrom(text, m); - } +// Variant E: broad scan first; only use isWellFormed when a valid pair is hit. +function lazyWellFormedSanitizeText(text: string): string { + NEEDS_RE_G.lastIndex = 0; + let m = NEEDS_RE_G.exec(text); + if (m === null) return text; + const first = m.index; + const firstCode = text.charCodeAt(first); + if (firstCode >= 0xd800 && firstCode <= 0xdbff && first + 1 < text.length) { + const lo = text.charCodeAt(first + 1); + if (lo >= 0xdc00 && lo <= 0xdfff && text.isWellFormed()) { + CONTROL_RE_G.lastIndex = first + 2; + m = CONTROL_RE_G.exec(text); + if (m === null) return text; + return sanitizeWellFormedControlFrom(text, m); + } + } + return sanitizeNeedsFrom(text, m); +} - function sanitizeWellFormedControlFrom(text: string, firstMatch: RegExpExecArray): string { - const len = text.length; - let out = ""; - let last = 0; - let m: RegExpExecArray | null = firstMatch; - while (m !== null) { - const i = m.index; - let removeLen = 1; - if (text.charCodeAt(i) === ESC) { - const ansiLen = ansiSeqLen(text, i); - if (ansiLen !== 0) removeLen = ansiLen; - } - if (last !== i) out += text.slice(last, i); - last = i + removeLen; - CONTROL_RE_G.lastIndex = last; - m = CONTROL_RE_G.exec(text); - } - if (last < len) out += text.slice(last); - return out; - } +function sanitizeWellFormedControlFrom(text: string, firstMatch: RegExpExecArray): string { + const len = text.length; + let out = ""; + let last = 0; + let m: RegExpExecArray | null = firstMatch; + while (m !== null) { + const i = m.index; + let removeLen = 1; + if (text.charCodeAt(i) === ESC) { + const ansiLen = ansiSeqLen(text, i); + if (ansiLen !== 0) removeLen = ansiLen; + } + if (last !== i) out += text.slice(last, i); + last = i + removeLen; + CONTROL_RE_G.lastIndex = last; + m = CONTROL_RE_G.exec(text); + } + if (last < len) out += text.slice(last); + return out; +} - function sanitizeNeedsFrom(text: string, firstMatch: RegExpExecArray): string { - const len = text.length; - let out = ""; - let last = 0; - let m: RegExpExecArray | null = firstMatch; - while (m !== null) { - const i = m.index; - const u = text.charCodeAt(i); - let removeLen = 0; - if (u === ESC) { - removeLen = ansiSeqLen(text, i); - } - if (removeLen === 0) { - if (u >= 0xd800 && u <= 0xdbff) { - if (i + 1 < len) { - const lo = text.charCodeAt(i + 1); - if (lo >= 0xdc00 && lo <= 0xdfff) { - NEEDS_RE_G.lastIndex = i + 2; - m = NEEDS_RE_G.exec(text); - continue; - } - } - removeLen = 1; - } else { - removeLen = 1; - } - } - if (last !== i) out += text.slice(last, i); - last = i + removeLen; - NEEDS_RE_G.lastIndex = last; - m = NEEDS_RE_G.exec(text); - } - if (last < len) out += text.slice(last); - return out; - } +function sanitizeNeedsFrom(text: string, firstMatch: RegExpExecArray): string { + const len = text.length; + let out = ""; + let last = 0; + let m: RegExpExecArray | null = firstMatch; + while (m !== null) { + const i = m.index; + const u = text.charCodeAt(i); + let removeLen = 0; + if (u === ESC) { + removeLen = ansiSeqLen(text, i); + } + if (removeLen === 0) { + if (u >= 0xd800 && u <= 0xdbff) { + if (i + 1 < len) { + const lo = text.charCodeAt(i + 1); + if (lo >= 0xdc00 && lo <= 0xdfff) { + NEEDS_RE_G.lastIndex = i + 2; + m = NEEDS_RE_G.exec(text); + continue; + } + } + removeLen = 1; + } else { + removeLen = 1; + } + } + if (last !== i) out += text.slice(last, i); + last = i + removeLen; + NEEDS_RE_G.lastIndex = last; + m = NEEDS_RE_G.exec(text); + } + if (last < len) out += text.slice(last); + return out; +} function sanitizeBinaryOutput(str: string): string { let out: string[] | undefined; @@ -284,7 +285,7 @@ function jsSanitizeText(text: string): string { const ITERATIONS = 2000; const bigPlain = "hello world ".repeat(500); -const bigAnsi = ("\x1b[31mred\x1b[0m " + "lorem ipsum dolor ".repeat(20)).repeat(5); +const bigAnsi = `\x1b[31mred\x1b[0m ${"lorem ipsum dolor ".repeat(20)}`.repeat(5); const samples = { plain: "hello world this is a plain ASCII string with some words", ansi: "\x1b[31mred text\x1b[0m and \x1b[4munderlined content\x1b[24m with emoji 😅😅", @@ -296,8 +297,6 @@ const samples = { bigAnsi, }; -const wrapWidth = 40; - const bench = makeBench(ITERATIONS); console.log(`Text layout benchmark (${ITERATIONS} iterations)\n`); @@ -311,7 +310,9 @@ for (const name in samples) { console.log(`MISMATCH js/current ${name}`); } if (regexResult !== baseline) { - console.log(`MISMATCH regex/current ${name}: regex=${JSON.stringify(regexResult)} baseline=${JSON.stringify(baseline)}`); + console.log( + `MISMATCH regex/current ${name}: regex=${JSON.stringify(regexResult)} baseline=${JSON.stringify(baseline)}`, + ); } const gatedResult = gatedSanitizeText(text); const skipResult = skipRunSanitizeText(text); @@ -322,16 +323,24 @@ for (const name in samples) { console.log(`MISMATCH gated/current ${name}`); } if (skipResult !== baseline) { - console.log(`MISMATCH skip/current ${name}: skip=${JSON.stringify(skipResult)} baseline=${JSON.stringify(baseline)}`); + console.log( + `MISMATCH skip/current ${name}: skip=${JSON.stringify(skipResult)} baseline=${JSON.stringify(baseline)}`, + ); } if (removalStartResult !== baseline) { - console.log(`MISMATCH removalStart/current ${name}: removalStart=${JSON.stringify(removalStartResult)} baseline=${JSON.stringify(baseline)}`); + console.log( + `MISMATCH removalStart/current ${name}: removalStart=${JSON.stringify(removalStartResult)} baseline=${JSON.stringify(baseline)}`, + ); } if (wellFormedControlResult !== baseline) { - console.log(`MISMATCH wellFormedControl/current ${name}: wellFormedControl=${JSON.stringify(wellFormedControlResult)} baseline=${JSON.stringify(baseline)}`); + console.log( + `MISMATCH wellFormedControl/current ${name}: wellFormedControl=${JSON.stringify(wellFormedControlResult)} baseline=${JSON.stringify(baseline)}`, + ); } if (lazyWellFormedResult !== baseline) { - console.log(`MISMATCH lazyWellFormed/current ${name}: lazyWellFormed=${JSON.stringify(lazyWellFormedResult)} baseline=${JSON.stringify(baseline)}`); + console.log( + `MISMATCH lazyWellFormed/current ${name}: lazyWellFormed=${JSON.stringify(lazyWellFormedResult)} baseline=${JSON.stringify(baseline)}`, + ); } bench(`jsSanitizeText/${name}`, () => { @@ -360,5 +369,3 @@ for (const name in samples) { }); console.log(); } - - diff --git a/packages/tui/bench/text-layout.ts b/packages/tui/bench/text-layout.ts index 650623ff2..de0474a44 100644 --- a/packages/tui/bench/text-layout.ts +++ b/packages/tui/bench/text-layout.ts @@ -1,5 +1,12 @@ -import { visibleWidth, wrapTextWithAnsi, truncateToWidth, sliceWithWidth, extractSegments, Ellipsis } from "../src/utils"; import { matchesKey } from "../src/keys"; +import { + Ellipsis, + extractSegments, + sliceWithWidth, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "../src/utils"; import { makeBench } from "./_harness"; const ITERATIONS = 2000; @@ -9,7 +16,8 @@ const samples = { ansi: "\x1b[31mred text\x1b[0m and \x1b[4munderlined content\x1b[24m with emoji 😅😅", links: "prefix \x1b]8;;https://example.com\x07link\x1b]8;;\x07 suffix", wide: "日本語のテキストとemoji 🚀✨ mixed with ascii", - wrapped: "This is a long line that should wrap multiple times when rendered with ANSI \x1b[32mcolors\x1b[0m and tabs\tbetween words.", + wrapped: + "This is a long line that should wrap multiple times when rendered with ANSI \x1b[32mcolors\x1b[0m and tabs\tbetween words.", }; const wrapWidth = 40; diff --git a/scripts/ci-release-publish.test.ts b/scripts/ci-release-publish.test.ts new file mode 100644 index 000000000..d5b3ba2d2 --- /dev/null +++ b/scripts/ci-release-publish.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test"; +import { packages, rewriteManifest } from "./ci-release-publish"; + +describe("published manifest topology", () => { + it("repoints omptype runtime entries to dist/js with a bun source condition", async () => { + const pkg = packages.find(entry => entry.dir === "packages/omptype"); + if (!pkg) throw new Error("omptype missing from publish set"); + expect(pkg.publishJs).toBe(true); + + const manifest = await rewriteManifest(pkg, false); + expect(manifest.main).toBe("./dist/js/index.js"); + expect(manifest.types).toBe("./dist/types/index.d.ts"); + expect(manifest.files).toContain("dist/js"); + expect(manifest.files).toContain("dist/types"); + // `src` must stay packed — the `bun` condition resolves into it. + expect(manifest.files).toContain("src"); + expect(manifest.exports).toEqual({ + ".": { + types: "./dist/types/index.d.ts", + bun: "./src/index.ts", + default: "./dist/js/index.js", + }, + "./*": { + types: "./dist/types/*.d.ts", + bun: "./src/*.ts", + default: "./dist/js/*.js", + }, + "./*.js": { + types: "./dist/types/*.d.ts", + bun: "./src/*.ts", + default: "./dist/js/*.js", + }, + }); + }); + + it("keeps source-runtime packages on src with only types repointed", async () => { + const pkg = packages.find(entry => entry.dir === "packages/utils"); + if (!pkg) throw new Error("utils missing from publish set"); + + const manifest = await rewriteManifest(pkg, false); + expect(manifest.main).toBe("./src/index.ts"); + expect(manifest.exports).toEqual({ + ".": { + types: "./dist/types/index.d.ts", + import: "./src/index.ts", + }, + "./*": { + types: "./dist/types/*.d.ts", + import: "./src/*.ts", + }, + "./*.js": "./src/*.ts", + }); + }); +}); diff --git a/scripts/ci-release-publish.ts b/scripts/ci-release-publish.ts index 453656908..61a3dfae1 100644 --- a/scripts/ci-release-publish.ts +++ b/scripts/ci-release-publish.ts @@ -14,9 +14,14 @@ * that points at `./src/*.ts(x)` is repointed to `./dist/types/*.d.ts`, * `dist/types` (plus `dist/client` for `stats`) is added to `files`, * and packages with a `publishBin` override get their `bin` swapped to - * the prepack bundle (coding-agent: `src/cli.ts` → `dist/cli.js`). The - * on-repo manifest keeps pointing at source so local dev and source - * installs (`bun link`, `install.sh --source`) work without a build. + * the prepack bundle (coding-agent: `src/cli.ts` → `dist/cli.js`). + * Packages flagged `publishJs` (omptype) additionally emit transpiled + * per-module JS into `dist/js/` and get their runtime entries (`main`, + * `exports[*]` import paths) repointed there, with a `bun` condition + * keeping TS-source resolution for Bun consumers — so the published + * package runs on plain Node. The on-repo manifest keeps pointing at + * source so local dev and source installs (`bun link`, + * `install.sh --source`) work without a build. * 3. Pack with `bun pm pack` (resolves the `catalog:`/`workspace:` * protocols npm cannot, and runs each package's `prepack` lifecycle), * then publish the resolved tarball with `npm publish` — see @@ -35,7 +40,7 @@ import { generateNpmPackages, LEAF_TARGETS, } from "../packages/natives/scripts/gen-npm-packages.ts"; -import { fixDtsExtensions } from "./fix-dts-extensions.ts"; +import { fixEmitExtensions } from "./fix-emit-extensions.ts"; export interface PublishPackage { dir: string; @@ -46,6 +51,12 @@ export interface PublishPackage { extraFiles?: readonly string[]; /** Extra tsgo invocations beyond `tsconfig.publish.json`. */ extraTypeConfigs?: readonly string[]; + /** + * Also emit transpiled JS to `dist/js` (via `tsconfig.publish.js.json`) + * and repoint the published runtime entries there so the package runs on + * plain Node. Requires the package to be dependency-free of Bun APIs. + */ + publishJs?: boolean; /** * `bin` map for the published manifest. The on-repo manifest points `bin` * at TS source so source installs (`bun link`, `install.sh --source`) work @@ -87,6 +98,7 @@ const nativeLeafTag = nativeLeafTagFromArgs(process.argv.slice(2)); export const packages: PublishPackage[] = [ { dir: "packages/utils", kind: "typescript" }, { dir: "packages/wire", kind: "typescript" }, + { dir: "packages/omptype", kind: "typescript", publishJs: true }, { dir: "packages/catalog", kind: "typescript" }, { dir: "packages/ai", kind: "typescript" }, { dir: "packages/natives", kind: "native" }, @@ -105,18 +117,30 @@ export const packages: PublishPackage[] = [ { dir: "packages/coding-agent", kind: "typescript", publishBin: { omp: "dist/cli.js" } }, ]; -function rewriteSrcPath(value: string): string { +function rewriteSrcToTypes(value: string): string { if (!value.startsWith("./src/")) return value; const rel = value.slice("./src/".length).replace(/\.tsx?$/, ""); return `./dist/types/${rel}.d.ts`; } -function rewriteExports(exports: JsonValue): JsonValue { +function rewriteSrcToJs(value: string): string { + if (!value.startsWith("./src/")) return value; + const rel = value.slice("./src/".length).replace(/\.tsx?$/, ""); + return `./dist/js/${rel}.js`; +} + +function rewriteExports(exports: JsonValue, publishJs: boolean): JsonValue { if (exports === null || typeof exports !== "object" || Array.isArray(exports)) return exports; const src = exports as JsonObject; const out: JsonObject = {}; for (const key in src) { const val = src[key]; + if (publishJs && typeof val === "string" && val.startsWith("./src/")) { + // String-form subpath (e.g. `"./*.js": "./src/*.ts"`): declarations + // for TS, TS source for Bun, transpiled JS for everything else. + out[key] = { types: rewriteSrcToTypes(val), bun: val, default: rewriteSrcToJs(val) }; + continue; + } if ( val !== null && typeof val === "object" && @@ -124,9 +148,16 @@ function rewriteExports(exports: JsonValue): JsonValue { typeof (val as JsonObject).types === "string" && ((val as JsonObject).types as string).startsWith("./src/") ) { - const next: JsonObject = { ...(val as JsonObject) }; - next.types = rewriteSrcPath(next.types as string); - out[key] = next; + const srcTypes = (val as JsonObject).types as string; + if (publishJs) { + // Condition order matters: `types` is TS-only, `bun` must win + // over `default` for Bun consumers. + out[key] = { types: rewriteSrcToTypes(srcTypes), bun: srcTypes, default: rewriteSrcToJs(srcTypes) }; + } else { + const next: JsonObject = { ...(val as JsonObject) }; + next.types = rewriteSrcToTypes(srcTypes); + out[key] = next; + } } else { out[key] = val; } @@ -134,17 +165,22 @@ function rewriteExports(exports: JsonValue): JsonValue { return out; } -async function rewriteManifest(pkg: PublishPackage, write: boolean): Promise { +/** Compute (and optionally write) the published manifest for a package. */ +export async function rewriteManifest(pkg: PublishPackage, write: boolean): Promise { const manifestPath = path.join(repoRoot, pkg.dir, "package.json"); const manifest = (await Bun.file(manifestPath).json()) as PackageManifest; if (pkg.publishBin) manifest.bin = { ...pkg.publishBin }; if (typeof manifest.types === "string" && manifest.types.startsWith("./src/")) { - manifest.types = rewriteSrcPath(manifest.types); + manifest.types = rewriteSrcToTypes(manifest.types); } - if (manifest.exports !== undefined) manifest.exports = rewriteExports(manifest.exports); + if (pkg.publishJs && typeof manifest.main === "string") { + manifest.main = rewriteSrcToJs(manifest.main); + } + if (manifest.exports !== undefined) manifest.exports = rewriteExports(manifest.exports, pkg.publishJs === true); const files = Array.isArray(manifest.files) ? [...manifest.files] : []; const hasDist = files.includes("dist"); if (!hasDist && !files.includes("dist/types")) files.push("dist/types"); + if (pkg.publishJs && !hasDist && !files.includes("dist/js")) files.push("dist/js"); for (const extra of pkg.extraFiles ?? []) { if (!hasDist && !files.includes(extra)) files.push(extra); } @@ -162,10 +198,16 @@ async function preparePackage(pkg: PublishPackage): Promise { for (const cfg of pkg.extraTypeConfigs ?? []) { await $`bun x tsgo -p ${cfg}`.cwd(pkgDir); } - // The declaration emit runs under `moduleResolution: "Bundler"`, so relative - // specifiers land extensionless — unresolvable for a `nodenext` consumer. - // Rewrite them to explicit `.js` so the published types resolve everywhere. - await fixDtsExtensions(path.join(pkgDir, "dist/types")); + if (pkg.publishJs) { + await $`bun x tsgo -p tsconfig.publish.js.json`.cwd(pkgDir); + } + // Both emits run under `moduleResolution: "Bundler"`, so relative + // specifiers land extensionless — unresolvable for a `nodenext` consumer + // (types) and for Node ESM at runtime (js). Rewrite them to explicit `.js`. + await fixEmitExtensions(path.join(pkgDir, "dist/types"), ".d.ts"); + if (pkg.publishJs) { + await fixEmitExtensions(path.join(pkgDir, "dist/js"), ".js"); + } return rewriteManifest(pkg, !isDryRun); } diff --git a/scripts/ci-test-ts.ts b/scripts/ci-test-ts.ts index cfad8b7b2..98798dd6f 100755 --- a/scripts/ci-test-ts.ts +++ b/scripts/ci-test-ts.ts @@ -88,6 +88,7 @@ const codingAgentBucketPlans: Record { + * given the directory of the importing emitted file and the extension the + * emitted tree uses (`.d.ts` for declarations, `.js` for runtime output). + * Returns null to leave as-is. */ +export async function resolveEmitSpecifier(fromDir: string, spec: string, ext: EmitExt): Promise { // Already has a JS/JSON extension, or a declaration extension we map to .js. if (/\.(js|json|mjs|cjs)$/.test(spec)) return null; if (/\.d\.ts$/.test(spec)) return `${spec.slice(0, -".d.ts".length)}.js`; const abs = path.join(fromDir, spec); - // Sibling declaration file: `./sdk` → `./sdk.js`. - if (await exists(`${abs}.d.ts`)) return `${spec}.js`; + // Sibling emitted file: `./sdk` → `./sdk.js`. + if (await exists(`${abs}${ext}`)) return `${spec}.js`; // Directory barrel: `./modes/components` → `./modes/components/index.js`. - if (await exists(path.join(abs, "index.d.ts"))) return `${spec.replace(/\/$/, "")}/index.js`; - // Unresolved (e.g. an asset or a specifier with no emitted `.d.ts`): leave it. + if (await exists(path.join(abs, `index${ext}`))) return `${spec.replace(/\/$/, "")}/index.js`; + // Unresolved (e.g. an asset or a specifier with no emitted file): leave it. return null; } @@ -53,8 +60,8 @@ async function exists(p: string): Promise { } } -/** Rewrite one `.d.ts` file in place. Returns the number of specifiers changed. */ -export async function fixDtsFile(filePath: string): Promise { +/** Rewrite one emitted file in place. Returns the number of specifiers changed. */ +export async function fixEmitFile(filePath: string, ext: EmitExt): Promise { const source = await Bun.file(filePath).text(); const fromDir = path.dirname(filePath); let changed = 0; @@ -63,7 +70,7 @@ export async function fixDtsFile(filePath: string): Promise { const edits: Array<{ match: string; replacement: string }> = []; for (const m of source.matchAll(SPECIFIER_RE)) { const [full, prefix, quote, spec] = m; - const resolved = await resolveDtsSpecifier(fromDir, spec); + const resolved = await resolveEmitSpecifier(fromDir, spec, ext); if (resolved && resolved !== spec) { edits.push({ match: full, replacement: `${prefix}${quote}${resolved}${quote}` }); } @@ -79,15 +86,15 @@ export async function fixDtsFile(filePath: string): Promise { return changed; } -/** Walk `dir` recursively and fix every `.d.ts`. Returns totals. */ -export async function fixDtsExtensions(dir: string): Promise<{ files: number; specifiers: number }> { +/** Walk `dir` recursively and fix every emitted file with extension `ext`. Returns totals. */ +export async function fixEmitExtensions(dir: string, ext: EmitExt): Promise<{ files: number; specifiers: number }> { let files = 0; let specifiers = 0; const entries = await fs.readdir(dir, { withFileTypes: true, recursive: true }); for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".d.ts")) continue; + if (!entry.isFile() || !entry.name.endsWith(ext)) continue; const filePath = path.join(entry.parentPath, entry.name); - const n = await fixDtsFile(filePath); + const n = await fixEmitFile(filePath, ext); if (n > 0) { files++; specifiers += n; @@ -98,10 +105,11 @@ export async function fixDtsExtensions(dir: string): Promise<{ files: number; sp if (import.meta.main) { const target = process.argv[2]; - if (!target) { - console.error("usage: fix-dts-extensions.ts "); + const ext = (process.argv[3] ?? ".d.ts") as EmitExt; + if (!target || (ext !== ".d.ts" && ext !== ".js")) { + console.error("usage: fix-emit-extensions.ts [.d.ts|.js]"); process.exit(1); } - const { files, specifiers } = await fixDtsExtensions(target); - console.log(`fix-dts-extensions: rewrote ${specifiers} specifiers across ${files} files in ${target}`); + const { files, specifiers } = await fixEmitExtensions(target, ext); + console.log(`fix-emit-extensions: rewrote ${specifiers} specifiers across ${files} files in ${target}`); } diff --git a/scripts/install-tests/run-ci.sh b/scripts/install-tests/run-ci.sh index d6dc7eac7..31ee8eee2 100755 --- a/scripts/install-tests/run-ci.sh +++ b/scripts/install-tests/run-ci.sh @@ -95,7 +95,7 @@ cp "$natives_pkg_backup" "$ROOT_DIR/packages/natives/package.json" # 3. Pack the remaining workspace packages (natives core and coding-agent # handled separately). `collab-web` is private but still packed here so its # prepack build and tarball file list stay release-safe. -for pkg in utils wire hashline catalog ai mnemopi snapcompact agent tui stats collab-web; do +for pkg in utils wire omptype hashline catalog ai mnemopi snapcompact agent tui stats collab-web; do ( cd "$ROOT_DIR/packages/$pkg" bun pm pack --destination "$TARBALL_DIR" --quiet >/dev/null @@ -120,6 +120,7 @@ cp "$agent_pkg_backup" "$ROOT_DIR/packages/coding-agent/package.json" utils_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-utils-*.tgz)" wire_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-wire-*.tgz)" +omptype_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-omptype-*.tgz)" natives_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-natives-[0-9]*.tgz)" natives_leaf_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-pi-natives-"$host_tag"-*.tgz)" hashline_tgz="$(find_tarball "$TARBALL_DIR"/oh-my-pi-hashline-*.tgz)" @@ -146,6 +147,7 @@ mkdir -p "$TARBALL_APP_DIR" pkg.overrides = { '@oh-my-pi/pi-utils': '$utils_tgz', '@oh-my-pi/pi-wire': '$wire_tgz', + '@oh-my-pi/omptype': '$omptype_tgz', '@oh-my-pi/pi-natives': '$natives_tgz', '@oh-my-pi/pi-natives-$host_tag': '$natives_leaf_tgz', '@oh-my-pi/hashline': '$hashline_tgz', @@ -162,7 +164,7 @@ mkdir -p "$TARBALL_APP_DIR" require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2)); " - bun add "$utils_tgz" "$wire_tgz" "$natives_tgz" "$hashline_tgz" "$catalog_tgz" "$ai_tgz" "$mnemopi_tgz" "$snapcompact_tgz" "$agent_tgz" "$tui_tgz" "$stats_tgz" "$coding_agent_tgz" "$collab_web_tgz" + bun add "$utils_tgz" "$wire_tgz" "$omptype_tgz" "$natives_tgz" "$hashline_tgz" "$catalog_tgz" "$ai_tgz" "$mnemopi_tgz" "$snapcompact_tgz" "$agent_tgz" "$tui_tgz" "$stats_tgz" "$coding_agent_tgz" "$collab_web_tgz" # The platform leaf must arrive through the core's optionalDependencies + # override, not as a direct dependency — assert it landed before smoking so a # resolution regression is distinguishable from a runtime loader bug. @@ -176,6 +178,19 @@ mkdir -p "$TARBALL_APP_DIR" echo "Unexpected @oh-my-pi/pi-wire COLLAB_PROTO: $wire_proto" exit 1 } + omptype_probe="$(bun -e ' + import { type } from "@oh-my-pi/omptype"; + import { Type } from "@oh-my-pi/omptype/typebox"; + import { z } from "@oh-my-pi/omptype/zod"; + const root = type({ name: "string", enabled: "boolean = false" }).assert({ name: "omp" }); + const typebox = Type.Object({ name: Type.String() }).assert({ name: "tb" }); + const zod = z.object({ name: z.string() }).parse({ name: "z" }); + process.stdout.write(`${root.name}:${root.enabled}:${typebox.name}:${zod.name}`); + ')" + [ "$omptype_probe" = "omp:false:tb:z" ] || { + echo "Unexpected @oh-my-pi/omptype probe result: $omptype_probe" + exit 1 + } [ -f "node_modules/@oh-my-pi/collab-web/dist/index.html" ] || { echo "Collab web tarball did not install built dist/index.html" exit 1